1import * as vulnScanner from "@distilled.cloud/cloudflare/vulnerability-scanner";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.VulnerabilityScanner.CredentialSet" as const;
15type TypeId = typeof TypeId;
16
17export interface VulnScannerCredentialSetProps {
18 /**
19 * Human-readable name for the credential set. If omitted, a unique name
20 * is generated from the app, stage, and logical ID.
21 * @default ${app}-${stage}-${id}
22 */
23 name?: string;
24}
25
26export interface VulnScannerCredentialSetAttributes {
27 /** Server-assigned credential set identifier (UUID). */
28 credentialSetId: string;
29 /** The Cloudflare account the credential set belongs to. */
30 accountId: string;
31 /** Human-readable name. */
32 name: string;
33}
34
35export type VulnScannerCredentialSet = Resource<
36 TypeId,
37 VulnScannerCredentialSetProps,
38 VulnScannerCredentialSetAttributes,
39 never,
40 Providers
41>;
42
43/**
44 * A Cloudflare Vulnerability Scanner credential set — a named container for
45 * authentication credentials (headers/cookies) the DAST scanner attaches to
46 * outgoing requests when scanning authenticated surfaces.
47 *
48 * Add individual credentials to the set with
49 * {@link VulnScannerCredential | `Cloudflare.VulnerabilityScanner.VulnScannerCredential`}.
50 * ### Creating a Credential Set
51 * **Example:** Default name
52 * ```typescript
53 * const creds = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet("scanner-creds", {});
54 * ```
55 *
56 * **Example:** Explicit name
57 * ```typescript
58 * const creds = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredentialSet("scanner-creds", {
59 * name: "staging-credentials",
60 * });
61 * ```
62 *
63 * ### Adding Credentials
64 * **Example:** Authorization header credential
65 * ```typescript
66 * const apiKey = yield* Cloudflare.VulnerabilityScanner.VulnScannerCredential("api-key", {
67 * credentialSetId: creds.credentialSetId,
68 * location: "header",
69 * locationName: "authorization", // Cloudflare requires lowercase names
70 * value: Redacted.make("Bearer ..."),
71 * });
72 * ```
73 *
74 * @see https://developers.cloudflare.com/security-center/
75 *
76 * @resource
77 * @product Vulnerability Scanner
78 * @category Application Security
79 */
80export const VulnScannerCredentialSet =
81 Resource<VulnScannerCredentialSet>(TypeId);
82
83/**
84 * Returns true if the given value is a VulnScannerCredentialSet resource.
85 */
86export const isVulnScannerCredentialSet = (
87 value: unknown,
88): value is VulnScannerCredentialSet =>
89 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
90
91export const VulnScannerCredentialSetProvider = () =>
92 Provider.succeed(VulnScannerCredentialSet, {
93 stables: ["credentialSetId", "accountId"],
94
95 list: Effect.fn(function* () {
96 const { accountId } = yield* yield* CloudflareEnvironment;
97 return yield* vulnScanner.listCredentialSets.pages({ accountId }).pipe(
98 Stream.runCollect,
99 Effect.map((chunk) =>
100 Array.from(chunk).flatMap((page) =>
101 (page.result ?? []).map((set) => toAttributes(set, accountId)),
102 ),
103 ),
104 );
105 }),
106
107 diff: Effect.fn(function* ({ news, output }) {
108 const { accountId } = yield* yield* CloudflareEnvironment;
109 if (!isResolved(news)) return undefined;
110 if ((output?.accountId ?? accountId) !== accountId) {
111 return { action: "replace" } as const;
112 }
113 return undefined;
114 }),
115
116 read: Effect.fn(function* ({ id, output, olds }) {
117 const { accountId } = yield* yield* CloudflareEnvironment;
118 const acct = output?.accountId ?? accountId;
119
120 // Owned path: refresh by the persisted UUID.
121 if (output?.credentialSetId) {
122 const observed = yield* observeCredentialSet(
123 acct,
124 output.credentialSetId,
125 );
126 return observed ? toAttributes(observed, acct) : undefined;
127 }
128
129 // Cold read: match the deterministic physical name. Names carry no
130 // ownership markers, so gate takeover behind the adopt policy.
131 const name = yield* createSetName(id, olds?.name);
132 const match = yield* findCredentialSetByName(acct, name);
133 return match ? Unowned(toAttributes(match, acct)) : undefined;
134 }),
135
136 reconcile: Effect.fn(function* ({ id, news, output }) {
137 const { accountId } = yield* yield* CloudflareEnvironment;
138 const name = yield* createSetName(id, news.name);
139
140 // 1. Observe — cached UUID first, then deterministic name (recovers
141 // from a crash between create and state persistence).
142 let observed = output?.credentialSetId
143 ? yield* observeCredentialSet(
144 output.accountId ?? accountId,
145 output.credentialSetId,
146 )
147 : undefined;
148 if (!observed) {
149 observed = yield* findCredentialSetByName(accountId, name);
150 }
151
152 // 2. Ensure — create when missing.
153 if (!observed) {
154 const created = yield* vulnScanner.createCredentialSet({
155 accountId,
156 name,
157 });
158 return toAttributes(created, accountId);
159 }
160
161 // 3. Sync — the only mutable aspect is the name; PUT when it drifts.
162 if (observed.name !== name) {
163 const updated = yield* vulnScanner.updateCredentialSet({
164 accountId,
165 credentialSetId: observed.id,
166 name,
167 });
168 return toAttributes(updated, accountId);
169 }
170
171 // 4. Return.
172 return toAttributes(observed, accountId);
173 }),
174
175 delete: Effect.fn(function* ({ output }) {
176 yield* vulnScanner
177 .deleteCredentialSet({
178 accountId: output.accountId,
179 credentialSetId: output.credentialSetId,
180 })
181 .pipe(Effect.catchTag("CredentialSetNotFound", () => Effect.void));
182 }),
183 });
184
185interface ObservedCredentialSet {
186 readonly id: string;
187 readonly name: string;
188}
189
190/**
191 * Read a credential set by id, mapping "gone" (`CredentialSetNotFound`,
192 * Cloudflare error code 14001) to `undefined`.
193 */
194const observeCredentialSet = (accountId: string, credentialSetId: string) =>
195 vulnScanner
196 .getCredentialSet({ accountId, credentialSetId })
197 .pipe(
198 Effect.catchTag("CredentialSetNotFound", () => Effect.succeed(undefined)),
199 );
200
201/**
202 * Find a credential set by exact name. Names are not unique on Cloudflare's
203 * side; if several match, pick the lexicographically smallest id for
204 * determinism.
205 */
206const findCredentialSetByName = (accountId: string, name: string) =>
207 vulnScanner.listCredentialSets.items({ accountId }).pipe(
208 Stream.filter((s) => s.name === name),
209 Stream.runCollect,
210 Effect.map((chunk): ObservedCredentialSet | undefined =>
211 Array.from(chunk)
212 .sort((a, b) => a.id.localeCompare(b.id))
213 .at(0),
214 ),
215 );
216
217const createSetName = (id: string, name: string | undefined) =>
218 Effect.gen(function* () {
219 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
220 });
221
222const toAttributes = (
223 set: ObservedCredentialSet,
224 accountId: string,
225): VulnScannerCredentialSetAttributes => ({
226 credentialSetId: set.id,
227 accountId,
228 name: set.name,
229});