import * as vulnScanner from "@distilled.cloud/cloudflare/vulnerability-scanner"; import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as Stream from "effect/Stream"; import { Unowned } from "../../AdoptPolicy.ts"; import { isResolved } from "../../Diff.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Providers } from "../Providers.ts"; const TypeId = "Cloudflare.VulnerabilityScanner.TargetEnvironment" as const; type TypeId = typeof TypeId; export interface VulnScannerTargetEnvironmentProps { /** * Human-readable name for the target environment. If omitted, a unique * name is generated from the app, stage, and logical ID. * @default ${app}-${stage}-${id} */ name?: string; /** * The zone the scanner targets (`target.zone_tag`). Scan history is tied * to the target, so changing the zone triggers a replacement. */ zoneId: string; /** * Optional description providing additional context. Mutable; omit to * clear an existing description. */ description?: string; } export interface VulnScannerTargetEnvironmentAttributes { /** Server-assigned target environment identifier (UUID). */ targetEnvironmentId: string; /** The Cloudflare account the target environment belongs to. */ accountId: string; /** Human-readable name. */ name: string; /** The zone being scanned (`target.zone_tag`). */ zoneId: string; /** Description, if set. */ description: string | undefined; } export type VulnScannerTargetEnvironment = Resource< TypeId, VulnScannerTargetEnvironmentProps, VulnScannerTargetEnvironmentAttributes, never, Providers >; /** * A Cloudflare Vulnerability Scanner target environment — declares which * zone the DAST-style web vulnerability scanner (Security Center, beta) is * allowed to scan. * * The environment is identified by a server-assigned UUID. `name` and * `description` are mutable in place; changing the target `zoneId` triggers * a replacement because scan history is tied to the target. * ### Creating a Target Environment * **Example:** Scan a zone * ```typescript * const zone = yield* Cloudflare.Zone.Zone("site", { name: "example.com" }); * * const target = yield* Cloudflare.VulnerabilityScanner.VulnScannerTargetEnvironment("site-scans", { * zoneId: zone.zoneId, * }); * ``` * * **Example:** With an explicit name and description * ```typescript * const target = yield* Cloudflare.VulnerabilityScanner.VulnScannerTargetEnvironment("site-scans", { * name: "production-site", * zoneId: zone.zoneId, * description: "Weekly DAST scan of the production zone", * }); * ``` * * @see https://developers.cloudflare.com/security-center/ * * @resource * @product Vulnerability Scanner * @category Application Security */ export const VulnScannerTargetEnvironment = Resource(TypeId); /** * Returns true if the given value is a VulnScannerTargetEnvironment resource. */ export const isVulnScannerTargetEnvironment = ( value: unknown, ): value is VulnScannerTargetEnvironment => Predicate.hasProperty(value, "Type") && value.Type === TypeId; export const VulnScannerTargetEnvironmentProvider = () => Provider.succeed(VulnScannerTargetEnvironment, { stables: ["targetEnvironmentId", "accountId"], // Account collection: exhaustively paginate the account-scoped target // environments list and hydrate each row into the `read` Attributes shape. list: Effect.fn(function* () { const { accountId } = yield* yield* CloudflareEnvironment; return yield* vulnScanner.listTargetEnvironments .pages({ accountId }) .pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => (page.result ?? []).map((env) => toAttributes(env, accountId)), ), ), ); }), diff: Effect.fn(function* ({ news, output }) { const { accountId } = yield* yield* CloudflareEnvironment; if (!isResolved(news)) return undefined; if ((output?.accountId ?? accountId) !== accountId) { return { action: "replace" } as const; } // The scan target is the environment's identity — scan history is // tied to it, so a different zone means a different environment. if ( output?.zoneId !== undefined && typeof news.zoneId === "string" && news.zoneId !== output.zoneId ) { return { action: "replace" } as const; } return undefined; }), read: Effect.fn(function* ({ id, output, olds }) { const { accountId } = yield* yield* CloudflareEnvironment; const acct = output?.accountId ?? accountId; // Owned path: refresh by the persisted UUID. if (output?.targetEnvironmentId) { const observed = yield* observeTargetEnvironment( acct, output.targetEnvironmentId, ); return observed ? toAttributes(observed, acct) : undefined; } // Cold read: recover from lost state by matching the deterministic // physical name. Names carry no ownership markers, so report the // match `Unowned` and let the engine gate takeover behind --adopt. const name = yield* createEnvironmentName(id, olds?.name); const match = yield* findTargetEnvironmentByName(acct, name); return match ? Unowned(toAttributes(match, acct)) : undefined; }), reconcile: Effect.fn(function* ({ id, news, output }) { const { accountId } = yield* yield* CloudflareEnvironment; const name = yield* createEnvironmentName(id, news.name); // Inputs are resolved to concrete values by the engine. const zoneId = news.zoneId as string; // 1. Observe — the UUID cached on `output` is a hint, not a // guarantee: a not-found falls through to "missing". let observed = output?.targetEnvironmentId ? yield* observeTargetEnvironment( output.accountId ?? accountId, output.targetEnvironmentId, ) : undefined; // Fall back to the deterministic name (e.g. crash after create but // before state persistence). Server UUIDs make duplicate-create the // failure mode to defend against here. if (!observed) { observed = yield* findTargetEnvironmentByName(accountId, name); } // 2. Ensure — create when missing. if (!observed) { const created = yield* vulnScanner.createTargetEnvironment({ accountId, name, target: { type: "zone", zoneTag: zoneId }, description: news.description, }); return toAttributes(created, accountId); } // 3. Sync — PUT the full desired body when anything drifts; skip the // API call entirely on a no-op. const desiredDescription = news.description; const observedDescription = observed.description ?? undefined; const dirty = observed.name !== name || observed.target.zoneTag !== zoneId || observedDescription !== desiredDescription; if (dirty) { const updated = yield* vulnScanner.updateTargetEnvironment({ accountId, targetEnvironmentId: observed.id, name, target: { type: "zone", zoneTag: zoneId }, // PUT with explicit null clears a stale description. description: desiredDescription ?? null, }); return toAttributes(updated, accountId); } // 4. Return. return toAttributes(observed, accountId); }), delete: Effect.fn(function* ({ output }) { yield* vulnScanner .deleteTargetEnvironment({ accountId: output.accountId, targetEnvironmentId: output.targetEnvironmentId, }) .pipe(Effect.catchTag("TargetEnvironmentNotFound", () => Effect.void)); }), }); interface ObservedTargetEnvironment { readonly id: string; readonly name: string; readonly target: { readonly type: "zone"; readonly zoneTag: string }; readonly description?: string | null; } /** * Read a target environment by id, mapping "gone" (`TargetEnvironmentNotFound`, * Cloudflare error code 11001) to `undefined`. */ const observeTargetEnvironment = ( accountId: string, targetEnvironmentId: string, ) => vulnScanner .getTargetEnvironment({ accountId, targetEnvironmentId }) .pipe( Effect.catchTag("TargetEnvironmentNotFound", () => Effect.succeed(undefined), ), ); /** * Find a target environment by exact name. Names are not unique on * Cloudflare's side; if several match, pick the lexicographically smallest * id for determinism. */ const findTargetEnvironmentByName = (accountId: string, name: string) => vulnScanner.listTargetEnvironments.items({ accountId }).pipe( Stream.filter((e) => e.name === name), Stream.runCollect, Effect.map((chunk): ObservedTargetEnvironment | undefined => Array.from(chunk) .sort((a, b) => a.id.localeCompare(b.id)) .at(0), ), ); const createEnvironmentName = (id: string, name: string | undefined) => Effect.gen(function* () { return name ?? (yield* createPhysicalName({ id, lowercase: true })); }); const toAttributes = ( env: ObservedTargetEnvironment, accountId: string, ): VulnScannerTargetEnvironmentAttributes => ({ targetEnvironmentId: env.id, accountId, name: env.name, zoneId: env.target.zoneTag, description: env.description ?? undefined, });