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.CredentialSet" as const; type TypeId = typeof TypeId; export interface VulnScannerCredentialSetProps { /** * Human-readable name for the credential set. If omitted, a unique name * is generated from the app, stage, and logical ID. * @default ${app}-${stage}-${id} */ name?: string; } export interface VulnScannerCredentialSetAttributes { /** Server-assigned credential set identifier (UUID). */ credentialSetId: string; /** The Cloudflare account the credential set belongs to. */ accountId: string; /** Human-readable name. */ name: string; } export type VulnScannerCredentialSet = Resource< TypeId, VulnScannerCredentialSetProps, VulnScannerCredentialSetAttributes, never, Providers >; /** * A Cloudflare Vulnerability Scanner credential set — a named container for * authentication credentials (headers/cookies) the DAST scanner attaches to * outgoing requests when scanning authenticated surfaces. * * Add individual credentials to the set with * {@link VulnScannerCredential | `Cloudflare.VulnerabilityScanner.VulnScannerCredential`}. * ### Creating a Credential Set * **Example:** Default name * ```typescript * const creds = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet("scanner-creds", {}); * ``` * * **Example:** Explicit name * ```typescript * const creds = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet("scanner-creds", { * name: "staging-credentials", * }); * ``` * * ### Adding Credentials * **Example:** Authorization header credential * ```typescript * const apiKey = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential("api-key", { * credentialSetId: creds.credentialSetId, * location: "header", * locationName: "authorization", // Cloudflare requires lowercase names * value: Redacted.make("Bearer ..."), * }); * ``` * * @see https://developers.cloudflare.com/security-center/ * * @resource * @product Vulnerability Scanner * @category Application Security */ export const VulnScannerCredentialSet = Resource(TypeId); /** * Returns true if the given value is a VulnScannerCredentialSet resource. */ export const isVulnScannerCredentialSet = ( value: unknown, ): value is VulnScannerCredentialSet => Predicate.hasProperty(value, "Type") && value.Type === TypeId; export const VulnScannerCredentialSetProvider = () => Provider.succeed(VulnScannerCredentialSet, { stables: ["credentialSetId", "accountId"], list: Effect.fn(function* () { const { accountId } = yield* yield* CloudflareEnvironment; return yield* vulnScanner.listCredentialSets.pages({ accountId }).pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => (page.result ?? []).map((set) => toAttributes(set, 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; } 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?.credentialSetId) { const observed = yield* observeCredentialSet( acct, output.credentialSetId, ); return observed ? toAttributes(observed, acct) : undefined; } // Cold read: match the deterministic physical name. Names carry no // ownership markers, so gate takeover behind the adopt policy. const name = yield* createSetName(id, olds?.name); const match = yield* findCredentialSetByName(acct, name); return match ? Unowned(toAttributes(match, acct)) : undefined; }), reconcile: Effect.fn(function* ({ id, news, output }) { const { accountId } = yield* yield* CloudflareEnvironment; const name = yield* createSetName(id, news.name); // 1. Observe — cached UUID first, then deterministic name (recovers // from a crash between create and state persistence). let observed = output?.credentialSetId ? yield* observeCredentialSet( output.accountId ?? accountId, output.credentialSetId, ) : undefined; if (!observed) { observed = yield* findCredentialSetByName(accountId, name); } // 2. Ensure — create when missing. if (!observed) { const created = yield* vulnScanner.createCredentialSet({ accountId, name, }); return toAttributes(created, accountId); } // 3. Sync — the only mutable aspect is the name; PUT when it drifts. if (observed.name !== name) { const updated = yield* vulnScanner.updateCredentialSet({ accountId, credentialSetId: observed.id, name, }); return toAttributes(updated, accountId); } // 4. Return. return toAttributes(observed, accountId); }), delete: Effect.fn(function* ({ output }) { yield* vulnScanner .deleteCredentialSet({ accountId: output.accountId, credentialSetId: output.credentialSetId, }) .pipe(Effect.catchTag("CredentialSetNotFound", () => Effect.void)); }), }); interface ObservedCredentialSet { readonly id: string; readonly name: string; } /** * Read a credential set by id, mapping "gone" (`CredentialSetNotFound`, * Cloudflare error code 14001) to `undefined`. */ const observeCredentialSet = (accountId: string, credentialSetId: string) => vulnScanner .getCredentialSet({ accountId, credentialSetId }) .pipe( Effect.catchTag("CredentialSetNotFound", () => Effect.succeed(undefined)), ); /** * Find a credential set by exact name. Names are not unique on Cloudflare's * side; if several match, pick the lexicographically smallest id for * determinism. */ const findCredentialSetByName = (accountId: string, name: string) => vulnScanner.listCredentialSets.items({ accountId }).pipe( Stream.filter((s) => s.name === name), Stream.runCollect, Effect.map((chunk): ObservedCredentialSet | undefined => Array.from(chunk) .sort((a, b) => a.id.localeCompare(b.id)) .at(0), ), ); const createSetName = (id: string, name: string | undefined) => Effect.gen(function* () { return name ?? (yield* createPhysicalName({ id, lowercase: true })); }); const toAttributes = ( set: ObservedCredentialSet, accountId: string, ): VulnScannerCredentialSetAttributes => ({ credentialSetId: set.id, accountId, name: set.name, });