import * as vulnScanner from "@distilled.cloud/cloudflare/vulnerability-scanner"; import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as Redacted from "effect/Redacted"; 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.Credential" as const; type TypeId = typeof TypeId; /** * Where the scanner attaches a credential in outgoing requests. */ export type VulnScannerCredentialLocation = "header" | "cookie"; export interface VulnScannerCredentialProps { /** * The credential set this credential belongs to. The parent is part of * the credential's API path, so changing it triggers a replacement. */ credentialSetId: string; /** * Human-readable name for the credential. If omitted, a unique name is * generated from the app, stage, and logical ID. * @default ${app}-${stage}-${id} */ name?: string; /** * Where the credential is attached in outgoing requests: as an HTTP * `header` or a `cookie`. Mutable. */ location: VulnScannerCredentialLocation; /** * Name of the header or cookie the credential is attached as * (e.g. `authorization`, `session_id`). Cloudflare requires the name in * normalized (lowercase) form and rejects e.g. `Authorization` with a * `BadRequest`. Mutable. */ locationName: string; /** * The credential value (e.g. API key, session token). Write-only — the * Cloudflare API never returns it, so rotation is detected by comparing * against the previously deployed value. */ value: Redacted.Redacted; } export interface VulnScannerCredentialAttributes { /** Server-assigned credential identifier (UUID). */ credentialId: string; /** The parent credential set identifier. */ credentialSetId: string; /** The Cloudflare account the credential belongs to. */ accountId: string; /** Human-readable name. */ name: string; /** Where the credential is attached in outgoing requests. */ location: VulnScannerCredentialLocation; /** Name of the header or cookie the credential is attached as. */ locationName: string; } export type VulnScannerCredential = Resource< TypeId, VulnScannerCredentialProps, VulnScannerCredentialAttributes, never, Providers >; /** * A credential inside a Cloudflare Vulnerability Scanner credential set — * an HTTP header or cookie value the DAST scanner attaches to outgoing * requests so it can scan authenticated surfaces. * * The credential `value` is write-only: Cloudflare never returns it, so the * provider rotates it by comparing the desired value against the previously * deployed one. `name`, `location`, and `locationName` are mutable in place; * moving the credential to a different set triggers a replacement. * ### Creating a Credential * **Example:** Authorization header * ```typescript * const creds = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet("scanner-creds", {}); * * const apiKey = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential("api-key", { * credentialSetId: creds.credentialSetId, * location: "header", * // Cloudflare requires normalized (lowercase) header/cookie names. * locationName: "authorization", * value: Redacted.make("Bearer my-api-key"), * }); * ``` * * **Example:** Session cookie * ```typescript * const session = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential("session", { * credentialSetId: creds.credentialSetId, * location: "cookie", * locationName: "session_id", * value: Redacted.make("s3cr3t-session-token"), * }); * ``` * * ### Rotating the Value * **Example:** Rotate by redeploying with a new value * ```typescript * // Change the redacted value and redeploy — the provider PUTs the new * // value even though the API never echoes it back. * const rotated = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential("api-key", { * credentialSetId: creds.credentialSetId, * location: "header", * locationName: "authorization", * value: Redacted.make("Bearer my-new-api-key"), * }); * ``` * * @see https://developers.cloudflare.com/security-center/ * * @resource * @product Vulnerability Scanner * @category Application Security */ export const VulnScannerCredential = Resource(TypeId); /** * Returns true if the given value is a VulnScannerCredential resource. */ export const isVulnScannerCredential = ( value: unknown, ): value is VulnScannerCredential => Predicate.hasProperty(value, "Type") && value.Type === TypeId; export const VulnScannerCredentialProvider = () => Provider.succeed(VulnScannerCredential, { stables: ["credentialId", "credentialSetId", "accountId"], // Credentials are sub-resources of a credential set and there is no // account-wide credential list. Enumerate every credential set in the // account, then fan out the per-set credential list and flatten. The // write-only `value` is never returned, so each row matches the exact // shape `read` produces. list: Effect.fn(function* () { const { accountId } = yield* yield* CloudflareEnvironment; const setIds = yield* vulnScanner.listCredentialSets .pages({ accountId }) .pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => (page.result ?? []).map((set) => set.id), ), ), ); const rows = yield* Effect.forEach( setIds, (credentialSetId) => vulnScanner.listCredentialSetCredentials .pages({ accountId, credentialSetId }) .pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => (page.result ?? []).map((c) => toAttributes(c, accountId)), ), ), // A set deleted mid-enumeration, or one we cannot access // (entitlement / permissions), contributes nothing. Effect.catchTag(["CredentialSetNotFound", "Forbidden"], () => Effect.succeed([] as VulnScannerCredentialAttributes[]), ), ), { concurrency: 10 }, ); return rows.flat(); }), 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 parent set is part of the credential's API path — a credential // cannot be moved between sets in place. if ( output?.credentialSetId !== undefined && typeof news.credentialSetId === "string" && news.credentialSetId !== output.credentialSetId ) { 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 UUIDs. if (output?.credentialId && output.credentialSetId) { const observed = yield* observeCredential( acct, output.credentialSetId, output.credentialId, ); return observed ? toAttributes(observed, acct) : undefined; } // Cold read: we need the parent set to look anything up. Match the // deterministic physical name within the set; names carry no // ownership markers, so gate takeover behind the adopt policy. const credentialSetId = output?.credentialSetId ?? (typeof olds?.credentialSetId === "string" ? olds.credentialSetId : undefined); if (!credentialSetId) return undefined; const name = yield* createCredentialName(id, olds?.name); const match = yield* findCredentialByName(acct, credentialSetId, name); return match ? Unowned(toAttributes(match, acct)) : undefined; }), reconcile: Effect.fn(function* ({ id, news, output, olds }) { const { accountId } = yield* yield* CloudflareEnvironment; const name = yield* createCredentialName(id, news.name); // Inputs are resolved to concrete values by the engine. const credentialSetId = news.credentialSetId as string; const value = Redacted.value(news.value); // 1. Observe — cached UUID first, then deterministic name within the // parent set (recovers from a crash between create and state // persistence). A deleted parent set surfaces the credential as // missing and falls through to create. let observed = output?.credentialId ? yield* observeCredential( output.accountId ?? accountId, credentialSetId, output.credentialId, ) : undefined; if (!observed) { observed = yield* findCredentialByName( accountId, credentialSetId, name, ); } // 2. Ensure — create when missing. if (!observed) { const created = yield* vulnScanner.createCredentialSetCredential({ accountId, credentialSetId, name, location: news.location, locationName: news.locationName, value, }); return toAttributes(created, accountId); } // 3. Sync — PUT the full body (the API requires `value` on PUT) when // any observable field drifts, or when the write-only value // changed between olds and news. The value is never readable, so // `olds.value` is the one sanctioned baseline for rotation. const oldValue = olds?.value === undefined ? undefined : Redacted.value(olds.value); const valueChanged = oldValue !== value; const dirty = observed.name !== name || observed.location !== news.location || observed.locationName !== news.locationName || valueChanged; if (dirty) { const updated = yield* vulnScanner.updateCredentialSetCredential({ accountId, credentialSetId, credentialId: observed.id, name, location: news.location, locationName: news.locationName, value, }); return toAttributes(updated, accountId); } // 4. Return. return toAttributes(observed, accountId); }), delete: Effect.fn(function* ({ output }) { yield* vulnScanner .deleteCredentialSetCredential({ accountId: output.accountId, credentialSetId: output.credentialSetId, credentialId: output.credentialId, }) .pipe( // Both "credential gone" and "parent set already gone" mean the // credential no longer exists — idempotent success. Effect.catchTag( ["CredentialNotFound", "CredentialSetNotFound"], () => Effect.void, ), ); }), }); /** * Read a credential by id, mapping "gone" (`CredentialNotFound` 14002, or * the parent set gone, `CredentialSetNotFound` 14001) to `undefined`. */ const observeCredential = ( accountId: string, credentialSetId: string, credentialId: string, ) => vulnScanner .getCredentialSetCredential({ accountId, credentialSetId, credentialId }) .pipe( Effect.catchTag(["CredentialNotFound", "CredentialSetNotFound"], () => Effect.succeed(undefined), ), ); /** * Find a credential by exact name within a set. Names are not unique on * Cloudflare's side; if several match, pick the lexicographically smallest * id for determinism. A missing parent set reads as "no match". */ const findCredentialByName = ( accountId: string, credentialSetId: string, name: string, ) => vulnScanner.listCredentialSetCredentials .items({ accountId, credentialSetId }) .pipe( Stream.filter((c) => c.name === name), Stream.runCollect, Effect.map((chunk) => Array.from(chunk) .sort((a, b) => a.id.localeCompare(b.id)) .at(0), ), Effect.catchTag("CredentialSetNotFound", () => Effect.succeed(undefined)), ); const createCredentialName = (id: string, name: string | undefined) => Effect.gen(function* () { return name ?? (yield* createPhysicalName({ id, lowercase: true })); }); const toAttributes = ( credential: vulnScanner.GetCredentialSetCredentialResponse, accountId: string, ): VulnScannerCredentialAttributes => ({ credentialId: credential.id, credentialSetId: credential.credentialSetId, accountId, name: credential.name, // Distilled widens generated string enums to open unions (`string & {}`). location: credential.location as VulnScannerCredentialLocation, locationName: credential.locationName, });