1import * as contentScanning from "@distilled.cloud/cloudflare/content-scanning";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Predicate from "effect/Predicate";
6import * as Stream from "effect/Stream";
7
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14import { listAllZones } from "../Zone/lookup.ts";
15
16const TypeId = "Cloudflare.ContentScanning.Expression" as const;
17type TypeId = typeof TypeId;
18
19export interface ExpressionProps {
20 /**
21 * Zone the custom scan expression belongs to. Stable — moving an
22 * expression between zones triggers a replacement.
23 */
24 zoneId: string;
25 /**
26 * Ruleset-language expression that locates the content to scan inside
27 * the request — e.g.
28 * `lookup_json_string(http.request.body.raw, "file")`.
29 *
30 * Immutable — the API has no update endpoint for expressions, so
31 * changing the payload triggers a replacement (create new, delete old).
32 */
33 payload: string;
34}
35
36export interface ExpressionAttributes {
37 /** Cloudflare-assigned identifier of the custom scan expression. */
38 expressionId: string;
39 /** Zone the expression belongs to. */
40 zoneId: string;
41 /** The ruleset-language expression locating the content to scan. */
42 payload: string;
43}
44
45export type Expression = Resource<
46 TypeId,
47 ExpressionProps,
48 ExpressionAttributes,
49 never,
50 Providers
51>;
52
53/**
54 * A custom scan expression ("payload") for WAF Content Scanning — tells the
55 * malicious-uploads scanner where to find encoded or nested content in the
56 * request body (`/zones/{zone_id}/content-upload-scan/payloads`).
57 *
58 * An expression's identity is its `payload` text within the zone: the API
59 * offers create/list/delete only (no update), so changing `payload`
60 * triggers a replacement. The zone must have Content Scanning enabled (see
61 * `Cloudflare.ContentScanning.ContentScanning`) — payload calls on a zone where scanning is
62 * disabled fail with the typed `ContentScanningNotEnabled` error.
63 *
64 * Safety: expressions carry no ownership markers. When there is no prior
65 * state, `read` scans the zone for an expression with the same payload text
66 * and reports it as `Unowned`, so the engine refuses to take it over unless
67 * `--adopt` (or `adopt(true)`) is set.
68 * ### Creating expressions
69 * **Example:** Scan a JSON-embedded file field
70 * ```typescript
71 * const scanning = yield* Cloudflare.ContentScanning.ContentScanning("UploadScanning", {
72 * zoneId: zone.zoneId,
73 * });
74 *
75 * yield* Cloudflare.ContentScanning.Expression("ScanJsonFile", {
76 * zoneId: scanning.zoneId,
77 * payload: 'lookup_json_string(http.request.body.raw, "file")',
78 * });
79 * ```
80 *
81 * **Example:** Scan a base64-encoded form field
82 * ```typescript
83 * yield* Cloudflare.ContentScanning.Expression("ScanBase64Document", {
84 * zoneId: scanning.zoneId,
85 * payload: 'base64_decode(http.request.body.form["document"][0])',
86 * });
87 * ```
88 *
89 * @see https://developers.cloudflare.com/waf/detections/malicious-uploads/#add-custom-scan-expressions
90 *
91 * @resource
92 * @product Content Scanning
93 * @category Application Security
94 */
95export const Expression = Resource<Expression>(TypeId);
96
97/**
98 * Returns true if the given value is a Expression resource.
99 */
100export const isExpression = (value: unknown): value is Expression =>
101 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
102
103/**
104 * Cloudflare accepted the create call but the expression did not appear in
105 * the returned list — an API anomaly that should never happen in practice.
106 */
107export class ExpressionCreateAnomaly extends Data.TaggedError(
108 "ExpressionCreateAnomaly",
109)<{
110 readonly zoneId: string;
111 readonly payload: string;
112}> {}
113
114export const ExpressionProvider = () =>
115 Provider.succeed(Expression, {
116 stables: ["expressionId", "zoneId", "payload"],
117
118 // Custom scan expressions are a zone-scoped Content Scanning feature. Fan
119 // out over every zone in the account, exhaustively paginate each zone's
120 // payloads, and hydrate each into the same Attributes shape `read`
121 // produces. Zones without Content Scanning enabled (or not entitled) reject
122 // payload calls with the typed `ContentScanningNotEnabled`/`Forbidden`
123 // tags, and a deleted zone with `InvalidRoute` — skip all three.
124 list: Effect.fn(function* () {
125 const { accountId } = yield* yield* CloudflareEnvironment;
126 const allZones = yield* listAllZones(accountId);
127 const rows = yield* Effect.forEach(
128 allZones,
129 (zone) =>
130 contentScanning.listPayloads.pages({ zoneId: zone.id }).pipe(
131 Stream.runCollect,
132 Effect.map((chunk) =>
133 Array.from(chunk).flatMap((page) =>
134 (page.result ?? []).map((expression) =>
135 toAttributes(zone.id, expression),
136 ),
137 ),
138 ),
139 Effect.catchTag("ContentScanningNotEnabled", () =>
140 Effect.succeed([]),
141 ),
142 Effect.catchTag("Forbidden", () => Effect.succeed([])),
143 Effect.catchTag("InvalidRoute", () => Effect.succeed([])),
144 ),
145 { concurrency: 10 },
146 );
147 return rows.flat();
148 }),
149
150 diff: Effect.fn(function* ({ olds, news, output }) {
151 // zoneId is Input<string>; compare only once both sides are concrete.
152 if (!isResolved(news)) return undefined;
153 const old = olds !== undefined && isResolved(olds) ? olds : undefined;
154 // The payload is the expression's identity — no update endpoint.
155 const oldPayload = output?.payload ?? old?.payload;
156 if (oldPayload !== undefined && oldPayload !== news.payload) {
157 return { action: "replace" } as const;
158 }
159 const oldZoneId = output?.zoneId ?? old?.zoneId;
160 if (oldZoneId !== undefined && oldZoneId !== news.zoneId) {
161 return { action: "replace" } as const;
162 }
163 return undefined;
164 }),
165
166 read: Effect.fn(function* ({ output, olds }) {
167 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
168 if (!zoneId) return undefined;
169
170 const expressions = yield* listExpressions(zoneId);
171 // Scanning disabled or zone gone — no expressions are observable.
172 if (expressions === undefined) return undefined;
173
174 // Owned path: refresh by our persisted expression id.
175 if (output?.expressionId) {
176 const observed = expressions.find((e) => e.id === output.expressionId);
177 if (observed) return toAttributes(zoneId, observed);
178 }
179
180 // Adoption path: an expression with this payload text may already
181 // exist on the zone. Expressions carry no ownership markers, so we
182 // cannot prove we created it — brand it `Unowned` so the engine
183 // refuses to take over unless `adopt` is set.
184 const payload = output?.payload ?? olds?.payload;
185 if (payload !== undefined) {
186 const observed = expressions.find((e) => e.payload === payload);
187 if (observed) return Unowned(toAttributes(zoneId, observed));
188 }
189 return undefined;
190 }),
191
192 reconcile: Effect.fn(function* ({ news, output }) {
193 // Inputs have been resolved to concrete strings by Plan.
194 const zoneId = news.zoneId as string;
195
196 // 1. Observe — the expression id cached on `output` is a hint, not
197 // a guarantee: a missing expression falls through to the payload
198 // scan and then to create. (Scanning must be enabled here — a
199 // disabled zone fails with the typed ContentScanningNotEnabled.)
200 const expressionId = output?.expressionId;
201 let observed: ObservedExpression | undefined = expressionId
202 ? yield* findExpression(zoneId, (e) => e.id === expressionId)
203 : undefined;
204
205 // 2. Fall back to matching by payload text. Ownership has already
206 // been verified upstream — `read` reports existing expressions as
207 // `Unowned` and the engine gates takeover behind the adopt policy
208 // before reconcile ever runs.
209 if (!observed) {
210 observed = yield* findExpression(
211 zoneId,
212 (e) => e.payload === news.payload,
213 );
214 }
215
216 // 3. Ensure — create when missing. The create call returns the full
217 // list of expressions; find ours by payload text.
218 if (!observed) {
219 const created = yield* contentScanning.createPayload({
220 zoneId,
221 body: [{ payload: news.payload }],
222 });
223 observed = created.result.find((e) => e.payload === news.payload);
224 if (!observed) {
225 return yield* Effect.fail(
226 new ExpressionCreateAnomaly({
227 zoneId,
228 payload: news.payload,
229 }),
230 );
231 }
232 }
233
234 // 4. No sync step — expressions have nothing mutable beyond identity.
235 return toAttributes(zoneId, observed);
236 }),
237
238 delete: Effect.fn(function* ({ output }) {
239 const { zoneId, expressionId } = output;
240 // Observe first — scanning disabled / zone gone / already deleted
241 // all mean there is nothing left to remove.
242 const expressions = yield* listExpressions(zoneId);
243 if (expressions === undefined) return;
244 if (!expressions.some((e) => e.id === expressionId)) return;
245 yield* contentScanning.deletePayload({ zoneId, expressionId }).pipe(
246 // A concurrent disable between observe and delete is "gone".
247 Effect.catchTag("ContentScanningNotEnabled", () => Effect.void),
248 Effect.catchTag("InvalidRoute", () => Effect.void),
249 );
250 }),
251 });
252
253type ObservedExpression = { id?: string | null; payload?: string | null };
254
255/**
256 * Find the first expression on the zone matching `predicate`, terminating
257 * pagination as soon as a match is seen. Unlike {@link listExpressions},
258 * "not observable" errors propagate (reconcile requires scanning enabled).
259 */
260const findExpression = (
261 zoneId: string,
262 predicate: (e: ObservedExpression) => boolean,
263) =>
264 contentScanning.listPayloads
265 .items({ zoneId })
266 .pipe(
267 Stream.filter(predicate),
268 Stream.runHead,
269 Effect.map(Option.getOrUndefined),
270 );
271
272/**
273 * List the zone's custom scan expressions, mapping "not observable"
274 * (scanning disabled on the zone, or the zone itself gone) to `undefined`.
275 */
276const listExpressions = (zoneId: string) =>
277 contentScanning.listPayloads.items({ zoneId }).pipe(
278 Stream.runCollect,
279 Effect.map((chunk): readonly ObservedExpression[] | undefined =>
280 Array.from(chunk),
281 ),
282 Effect.catchTag("ContentScanningNotEnabled", () =>
283 Effect.succeed(undefined),
284 ),
285 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
286 );
287
288const toAttributes = (
289 zoneId: string,
290 expression: ObservedExpression,
291): ExpressionAttributes => ({
292 // Cloudflare always echoes both fields for a persisted expression.
293 expressionId: expression.id ?? "",
294 zoneId,
295 payload: expression.payload ?? "",
296});