1import * as vulnScanner from "@distilled.cloud/cloudflare/vulnerability-scanner";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.VulnerabilityScanner.Credential" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * Where the scanner attaches a credential in outgoing requests.
20 */
21export type VulnScannerCredentialLocation = "header" | "cookie";
22
23export interface VulnScannerCredentialProps {
24 /**
25 * The credential set this credential belongs to. The parent is part of
26 * the credential's API path, so changing it triggers a replacement.
27 */
28 credentialSetId: string;
29 /**
30 * Human-readable name for the credential. If omitted, a unique name is
31 * generated from the app, stage, and logical ID.
32 * @default ${app}-${stage}-${id}
33 */
34 name?: string;
35 /**
36 * Where the credential is attached in outgoing requests: as an HTTP
37 * `header` or a `cookie`. Mutable.
38 */
39 location: VulnScannerCredentialLocation;
40 /**
41 * Name of the header or cookie the credential is attached as
42 * (e.g. `authorization`, `session_id`). Cloudflare requires the name in
43 * normalized (lowercase) form and rejects e.g. `Authorization` with a
44 * `BadRequest`. Mutable.
45 */
46 locationName: string;
47 /**
48 * The credential value (e.g. API key, session token). Write-only — the
49 * Cloudflare API never returns it, so rotation is detected by comparing
50 * against the previously deployed value.
51 */
52 value: Redacted.Redacted<string>;
53}
54
55export interface VulnScannerCredentialAttributes {
56 /** Server-assigned credential identifier (UUID). */
57 credentialId: string;
58 /** The parent credential set identifier. */
59 credentialSetId: string;
60 /** The Cloudflare account the credential belongs to. */
61 accountId: string;
62 /** Human-readable name. */
63 name: string;
64 /** Where the credential is attached in outgoing requests. */
65 location: VulnScannerCredentialLocation;
66 /** Name of the header or cookie the credential is attached as. */
67 locationName: string;
68}
69
70export type VulnScannerCredential = Resource<
71 TypeId,
72 VulnScannerCredentialProps,
73 VulnScannerCredentialAttributes,
74 never,
75 Providers
76>;
77
78/**
79 * A credential inside a Cloudflare Vulnerability Scanner credential set —
80 * an HTTP header or cookie value the DAST scanner attaches to outgoing
81 * requests so it can scan authenticated surfaces.
82 *
83 * The credential `value` is write-only: Cloudflare never returns it, so the
84 * provider rotates it by comparing the desired value against the previously
85 * deployed one. `name`, `location`, and `locationName` are mutable in place;
86 * moving the credential to a different set triggers a replacement.
87 * ### Creating a Credential
88 * **Example:** Authorization header
89 * ```typescript
90 * const creds = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet("scanner-creds", {});
91 *
92 * const apiKey = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential("api-key", {
93 * credentialSetId: creds.credentialSetId,
94 * location: "header",
95 * // Cloudflare requires normalized (lowercase) header/cookie names.
96 * locationName: "authorization",
97 * value: Redacted.make("Bearer my-api-key"),
98 * });
99 * ```
100 *
101 * **Example:** Session cookie
102 * ```typescript
103 * const session = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential("session", {
104 * credentialSetId: creds.credentialSetId,
105 * location: "cookie",
106 * locationName: "session_id",
107 * value: Redacted.make("s3cr3t-session-token"),
108 * });
109 * ```
110 *
111 * ### Rotating the Value
112 * **Example:** Rotate by redeploying with a new value
113 * ```typescript
114 * // Change the redacted value and redeploy — the provider PUTs the new
115 * // value even though the API never echoes it back.
116 * const rotated = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential("api-key", {
117 * credentialSetId: creds.credentialSetId,
118 * location: "header",
119 * locationName: "authorization",
120 * value: Redacted.make("Bearer my-new-api-key"),
121 * });
122 * ```
123 *
124 * @see https://developers.cloudflare.com/security-center/
125 *
126 * @resource
127 * @product Vulnerability Scanner
128 * @category Application Security
129 */
130export const VulnScannerCredential = Resource<VulnScannerCredential>(TypeId);
131
132/**
133 * Returns true if the given value is a VulnScannerCredential resource.
134 */
135export const isVulnScannerCredential = (
136 value: unknown,
137): value is VulnScannerCredential =>
138 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
139
140export const VulnScannerCredentialProvider = () =>
141 Provider.succeed(VulnScannerCredential, {
142 stables: ["credentialId", "credentialSetId", "accountId"],
143
144 // Credentials are sub-resources of a credential set and there is no
145 // account-wide credential list. Enumerate every credential set in the
146 // account, then fan out the per-set credential list and flatten. The
147 // write-only `value` is never returned, so each row matches the exact
148 // shape `read` produces.
149 list: Effect.fn(function* () {
150 const { accountId } = yield* yield* CloudflareEnvironment;
151 const setIds = yield* vulnScanner.listCredentialSets
152 .pages({ accountId })
153 .pipe(
154 Stream.runCollect,
155 Effect.map((chunk) =>
156 Array.from(chunk).flatMap((page) =>
157 (page.result ?? []).map((set) => set.id),
158 ),
159 ),
160 );
161 const rows = yield* Effect.forEach(
162 setIds,
163 (credentialSetId) =>
164 vulnScanner.listCredentialSetCredentials
165 .pages({ accountId, credentialSetId })
166 .pipe(
167 Stream.runCollect,
168 Effect.map((chunk) =>
169 Array.from(chunk).flatMap((page) =>
170 (page.result ?? []).map((c) => toAttributes(c, accountId)),
171 ),
172 ),
173 // A set deleted mid-enumeration, or one we cannot access
174 // (entitlement / permissions), contributes nothing.
175 Effect.catchTag(["CredentialSetNotFound", "Forbidden"], () =>
176 Effect.succeed([] as VulnScannerCredentialAttributes[]),
177 ),
178 ),
179 { concurrency: 10 },
180 );
181 return rows.flat();
182 }),
183
184 diff: Effect.fn(function* ({ news, output }) {
185 const { accountId } = yield* yield* CloudflareEnvironment;
186 if (!isResolved(news)) return undefined;
187 if ((output?.accountId ?? accountId) !== accountId) {
188 return { action: "replace" } as const;
189 }
190 // The parent set is part of the credential's API path — a credential
191 // cannot be moved between sets in place.
192 if (
193 output?.credentialSetId !== undefined &&
194 typeof news.credentialSetId === "string" &&
195 news.credentialSetId !== output.credentialSetId
196 ) {
197 return { action: "replace" } as const;
198 }
199 return undefined;
200 }),
201
202 read: Effect.fn(function* ({ id, output, olds }) {
203 const { accountId } = yield* yield* CloudflareEnvironment;
204 const acct = output?.accountId ?? accountId;
205
206 // Owned path: refresh by the persisted UUIDs.
207 if (output?.credentialId && output.credentialSetId) {
208 const observed = yield* observeCredential(
209 acct,
210 output.credentialSetId,
211 output.credentialId,
212 );
213 return observed ? toAttributes(observed, acct) : undefined;
214 }
215
216 // Cold read: we need the parent set to look anything up. Match the
217 // deterministic physical name within the set; names carry no
218 // ownership markers, so gate takeover behind the adopt policy.
219 const credentialSetId =
220 output?.credentialSetId ??
221 (typeof olds?.credentialSetId === "string"
222 ? olds.credentialSetId
223 : undefined);
224 if (!credentialSetId) return undefined;
225 const name = yield* createCredentialName(id, olds?.name);
226 const match = yield* findCredentialByName(acct, credentialSetId, name);
227 return match ? Unowned(toAttributes(match, acct)) : undefined;
228 }),
229
230 reconcile: Effect.fn(function* ({ id, news, output, olds }) {
231 const { accountId } = yield* yield* CloudflareEnvironment;
232 const name = yield* createCredentialName(id, news.name);
233 // Inputs are resolved to concrete values by the engine.
234 const credentialSetId = news.credentialSetId as string;
235 const value = Redacted.value(news.value);
236
237 // 1. Observe — cached UUID first, then deterministic name within the
238 // parent set (recovers from a crash between create and state
239 // persistence). A deleted parent set surfaces the credential as
240 // missing and falls through to create.
241 let observed = output?.credentialId
242 ? yield* observeCredential(
243 output.accountId ?? accountId,
244 credentialSetId,
245 output.credentialId,
246 )
247 : undefined;
248 if (!observed) {
249 observed = yield* findCredentialByName(
250 accountId,
251 credentialSetId,
252 name,
253 );
254 }
255
256 // 2. Ensure — create when missing.
257 if (!observed) {
258 const created = yield* vulnScanner.createCredentialSetCredential({
259 accountId,
260 credentialSetId,
261 name,
262 location: news.location,
263 locationName: news.locationName,
264 value,
265 });
266 return toAttributes(created, accountId);
267 }
268
269 // 3. Sync — PUT the full body (the API requires `value` on PUT) when
270 // any observable field drifts, or when the write-only value
271 // changed between olds and news. The value is never readable, so
272 // `olds.value` is the one sanctioned baseline for rotation.
273 const oldValue =
274 olds?.value === undefined ? undefined : Redacted.value(olds.value);
275 const valueChanged = oldValue !== value;
276 const dirty =
277 observed.name !== name ||
278 observed.location !== news.location ||
279 observed.locationName !== news.locationName ||
280 valueChanged;
281 if (dirty) {
282 const updated = yield* vulnScanner.updateCredentialSetCredential({
283 accountId,
284 credentialSetId,
285 credentialId: observed.id,
286 name,
287 location: news.location,
288 locationName: news.locationName,
289 value,
290 });
291 return toAttributes(updated, accountId);
292 }
293
294 // 4. Return.
295 return toAttributes(observed, accountId);
296 }),
297
298 delete: Effect.fn(function* ({ output }) {
299 yield* vulnScanner
300 .deleteCredentialSetCredential({
301 accountId: output.accountId,
302 credentialSetId: output.credentialSetId,
303 credentialId: output.credentialId,
304 })
305 .pipe(
306 // Both "credential gone" and "parent set already gone" mean the
307 // credential no longer exists — idempotent success.
308 Effect.catchTag(
309 ["CredentialNotFound", "CredentialSetNotFound"],
310 () => Effect.void,
311 ),
312 );
313 }),
314 });
315
316/**
317 * Read a credential by id, mapping "gone" (`CredentialNotFound` 14002, or
318 * the parent set gone, `CredentialSetNotFound` 14001) to `undefined`.
319 */
320const observeCredential = (
321 accountId: string,
322 credentialSetId: string,
323 credentialId: string,
324) =>
325 vulnScanner
326 .getCredentialSetCredential({ accountId, credentialSetId, credentialId })
327 .pipe(
328 Effect.catchTag(["CredentialNotFound", "CredentialSetNotFound"], () =>
329 Effect.succeed(undefined),
330 ),
331 );
332
333/**
334 * Find a credential by exact name within a set. Names are not unique on
335 * Cloudflare's side; if several match, pick the lexicographically smallest
336 * id for determinism. A missing parent set reads as "no match".
337 */
338const findCredentialByName = (
339 accountId: string,
340 credentialSetId: string,
341 name: string,
342) =>
343 vulnScanner.listCredentialSetCredentials
344 .items({ accountId, credentialSetId })
345 .pipe(
346 Stream.filter((c) => c.name === name),
347 Stream.runCollect,
348 Effect.map((chunk) =>
349 Array.from(chunk)
350 .sort((a, b) => a.id.localeCompare(b.id))
351 .at(0),
352 ),
353 Effect.catchTag("CredentialSetNotFound", () => Effect.succeed(undefined)),
354 );
355
356const createCredentialName = (id: string, name: string | undefined) =>
357 Effect.gen(function* () {
358 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
359 });
360
361const toAttributes = (
362 credential: vulnScanner.GetCredentialSetCredentialResponse,
363 accountId: string,
364): VulnScannerCredentialAttributes => ({
365 credentialId: credential.id,
366 credentialSetId: credential.credentialSetId,
367 accountId,
368 name: credential.name,
369 // Distilled widens generated string enums to open unions (`string & {}`).
370 location: credential.location as VulnScannerCredentialLocation,
371 locationName: credential.locationName,
372});