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.TargetEnvironment" as const;
15type TypeId = typeof TypeId;
16
17export interface VulnScannerTargetEnvironmentProps {
18 /**
19 * Human-readable name for the target environment. If omitted, a unique
20 * name is generated from the app, stage, and logical ID.
21 * @default ${app}-${stage}-${id}
22 */
23 name?: string;
24 /**
25 * The zone the scanner targets (`target.zone_tag`). Scan history is tied
26 * to the target, so changing the zone triggers a replacement.
27 */
28 zoneId: string;
29 /**
30 * Optional description providing additional context. Mutable; omit to
31 * clear an existing description.
32 */
33 description?: string;
34}
35
36export interface VulnScannerTargetEnvironmentAttributes {
37 /** Server-assigned target environment identifier (UUID). */
38 targetEnvironmentId: string;
39 /** The Cloudflare account the target environment belongs to. */
40 accountId: string;
41 /** Human-readable name. */
42 name: string;
43 /** The zone being scanned (`target.zone_tag`). */
44 zoneId: string;
45 /** Description, if set. */
46 description: string | undefined;
47}
48
49export type VulnScannerTargetEnvironment = Resource<
50 TypeId,
51 VulnScannerTargetEnvironmentProps,
52 VulnScannerTargetEnvironmentAttributes,
53 never,
54 Providers
55>;
56
57/**
58 * A Cloudflare Vulnerability Scanner target environment — declares which
59 * zone the DAST-style web vulnerability scanner (Security Center, beta) is
60 * allowed to scan.
61 *
62 * The environment is identified by a server-assigned UUID. `name` and
63 * `description` are mutable in place; changing the target `zoneId` triggers
64 * a replacement because scan history is tied to the target.
65 * ### Creating a Target Environment
66 * **Example:** Scan a zone
67 * ```typescript
68 * const zone = yield* Cloudflare.Zone.Zone("site", { name: "example.com" });
69 *
70 * const target = yield* Cloudflare.VulnerabilityScanner.VulnScannerTargetEnvironment("site-scans", {
71 * zoneId: zone.zoneId,
72 * });
73 * ```
74 *
75 * **Example:** With an explicit name and description
76 * ```typescript
77 * const target = yield* Cloudflare.VulnerabilityScanner.VulnScannerTargetEnvironment("site-scans", {
78 * name: "production-site",
79 * zoneId: zone.zoneId,
80 * description: "Weekly DAST scan of the production zone",
81 * });
82 * ```
83 *
84 * @see https://developers.cloudflare.com/security-center/
85 *
86 * @resource
87 * @product Vulnerability Scanner
88 * @category Application Security
89 */
90export const VulnScannerTargetEnvironment =
91 Resource<VulnScannerTargetEnvironment>(TypeId);
92
93/**
94 * Returns true if the given value is a VulnScannerTargetEnvironment resource.
95 */
96export const isVulnScannerTargetEnvironment = (
97 value: unknown,
98): value is VulnScannerTargetEnvironment =>
99 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
100
101export const VulnScannerTargetEnvironmentProvider = () =>
102 Provider.succeed(VulnScannerTargetEnvironment, {
103 stables: ["targetEnvironmentId", "accountId"],
104
105 // Account collection: exhaustively paginate the account-scoped target
106 // environments list and hydrate each row into the `read` Attributes shape.
107 list: Effect.fn(function* () {
108 const { accountId } = yield* yield* CloudflareEnvironment;
109 return yield* vulnScanner.listTargetEnvironments
110 .pages({ accountId })
111 .pipe(
112 Stream.runCollect,
113 Effect.map((chunk) =>
114 Array.from(chunk).flatMap((page) =>
115 (page.result ?? []).map((env) => toAttributes(env, accountId)),
116 ),
117 ),
118 );
119 }),
120
121 diff: Effect.fn(function* ({ news, output }) {
122 const { accountId } = yield* yield* CloudflareEnvironment;
123 if (!isResolved(news)) return undefined;
124 if ((output?.accountId ?? accountId) !== accountId) {
125 return { action: "replace" } as const;
126 }
127 // The scan target is the environment's identity — scan history is
128 // tied to it, so a different zone means a different environment.
129 if (
130 output?.zoneId !== undefined &&
131 typeof news.zoneId === "string" &&
132 news.zoneId !== output.zoneId
133 ) {
134 return { action: "replace" } as const;
135 }
136 return undefined;
137 }),
138
139 read: Effect.fn(function* ({ id, output, olds }) {
140 const { accountId } = yield* yield* CloudflareEnvironment;
141 const acct = output?.accountId ?? accountId;
142
143 // Owned path: refresh by the persisted UUID.
144 if (output?.targetEnvironmentId) {
145 const observed = yield* observeTargetEnvironment(
146 acct,
147 output.targetEnvironmentId,
148 );
149 return observed ? toAttributes(observed, acct) : undefined;
150 }
151
152 // Cold read: recover from lost state by matching the deterministic
153 // physical name. Names carry no ownership markers, so report the
154 // match `Unowned` and let the engine gate takeover behind --adopt.
155 const name = yield* createEnvironmentName(id, olds?.name);
156 const match = yield* findTargetEnvironmentByName(acct, name);
157 return match ? Unowned(toAttributes(match, acct)) : undefined;
158 }),
159
160 reconcile: Effect.fn(function* ({ id, news, output }) {
161 const { accountId } = yield* yield* CloudflareEnvironment;
162 const name = yield* createEnvironmentName(id, news.name);
163 // Inputs are resolved to concrete values by the engine.
164 const zoneId = news.zoneId as string;
165
166 // 1. Observe — the UUID cached on `output` is a hint, not a
167 // guarantee: a not-found falls through to "missing".
168 let observed = output?.targetEnvironmentId
169 ? yield* observeTargetEnvironment(
170 output.accountId ?? accountId,
171 output.targetEnvironmentId,
172 )
173 : undefined;
174 // Fall back to the deterministic name (e.g. crash after create but
175 // before state persistence). Server UUIDs make duplicate-create the
176 // failure mode to defend against here.
177 if (!observed) {
178 observed = yield* findTargetEnvironmentByName(accountId, name);
179 }
180
181 // 2. Ensure — create when missing.
182 if (!observed) {
183 const created = yield* vulnScanner.createTargetEnvironment({
184 accountId,
185 name,
186 target: { type: "zone", zoneTag: zoneId },
187 description: news.description,
188 });
189 return toAttributes(created, accountId);
190 }
191
192 // 3. Sync — PUT the full desired body when anything drifts; skip the
193 // API call entirely on a no-op.
194 const desiredDescription = news.description;
195 const observedDescription = observed.description ?? undefined;
196 const dirty =
197 observed.name !== name ||
198 observed.target.zoneTag !== zoneId ||
199 observedDescription !== desiredDescription;
200 if (dirty) {
201 const updated = yield* vulnScanner.updateTargetEnvironment({
202 accountId,
203 targetEnvironmentId: observed.id,
204 name,
205 target: { type: "zone", zoneTag: zoneId },
206 // PUT with explicit null clears a stale description.
207 description: desiredDescription ?? null,
208 });
209 return toAttributes(updated, accountId);
210 }
211
212 // 4. Return.
213 return toAttributes(observed, accountId);
214 }),
215
216 delete: Effect.fn(function* ({ output }) {
217 yield* vulnScanner
218 .deleteTargetEnvironment({
219 accountId: output.accountId,
220 targetEnvironmentId: output.targetEnvironmentId,
221 })
222 .pipe(Effect.catchTag("TargetEnvironmentNotFound", () => Effect.void));
223 }),
224 });
225
226interface ObservedTargetEnvironment {
227 readonly id: string;
228 readonly name: string;
229 readonly target: { readonly type: "zone"; readonly zoneTag: string };
230 readonly description?: string | null;
231}
232
233/**
234 * Read a target environment by id, mapping "gone" (`TargetEnvironmentNotFound`,
235 * Cloudflare error code 11001) to `undefined`.
236 */
237const observeTargetEnvironment = (
238 accountId: string,
239 targetEnvironmentId: string,
240) =>
241 vulnScanner
242 .getTargetEnvironment({ accountId, targetEnvironmentId })
243 .pipe(
244 Effect.catchTag("TargetEnvironmentNotFound", () =>
245 Effect.succeed(undefined),
246 ),
247 );
248
249/**
250 * Find a target environment by exact name. Names are not unique on
251 * Cloudflare's side; if several match, pick the lexicographically smallest
252 * id for determinism.
253 */
254const findTargetEnvironmentByName = (accountId: string, name: string) =>
255 vulnScanner.listTargetEnvironments.items({ accountId }).pipe(
256 Stream.filter((e) => e.name === name),
257 Stream.runCollect,
258 Effect.map((chunk): ObservedTargetEnvironment | undefined =>
259 Array.from(chunk)
260 .sort((a, b) => a.id.localeCompare(b.id))
261 .at(0),
262 ),
263 );
264
265const createEnvironmentName = (id: string, name: string | undefined) =>
266 Effect.gen(function* () {
267 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
268 });
269
270const toAttributes = (
271 env: ObservedTargetEnvironment,
272 accountId: string,
273): VulnScannerTargetEnvironmentAttributes => ({
274 targetEnvironmentId: env.id,
275 accountId,
276 name: env.name,
277 zoneId: env.target.zoneTag,
278 description: env.description ?? undefined,
279});