1import * as lcc from "@distilled.cloud/cloudflare/leaked-credential-checks";
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 * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13const TypeId = "Cloudflare.LeakedCredentialCheck.Detection" as const;
14type TypeId = typeof TypeId;
15
16export interface LeakedCredentialDetectionProps {
17 /**
18 * Zone the custom detection belongs to. Stable — moving a detection
19 * between zones triggers a replacement.
20 */
21 zoneId: string;
22 /**
23 * Ruleset expression locating the username in the request, e.g.
24 * `lookup_json_string(http.request.body.raw, "user")`. Mutable —
25 * updated in place via PUT. At least one of `username`/`password`
26 * should be set.
27 */
28 username?: string;
29 /**
30 * Ruleset expression locating the password in the request, e.g.
31 * `lookup_json_string(http.request.body.raw, "secret")`. This is an
32 * expression over the request (not a secret value). Mutable — updated
33 * in place via PUT.
34 */
35 password?: string;
36}
37
38export interface LeakedCredentialDetectionAttributes {
39 /** Cloudflare-assigned identifier of the custom detection. */
40 detectionId: string;
41 /** Zone the detection belongs to. */
42 zoneId: string;
43 /** The username-locating ruleset expression, if set. */
44 username: string | undefined;
45 /** The password-locating ruleset expression, if set. */
46 password: string | undefined;
47}
48
49export type LeakedCredentialDetection = Resource<
50 TypeId,
51 LeakedCredentialDetectionProps,
52 LeakedCredentialDetectionAttributes,
53 never,
54 Providers
55>;
56
57/**
58 * A custom detection location for Cloudflare Leaked Credential Checks
59 * (`/zones/{zone_id}/leaked-credential-checks/detections`) — a pair of
60 * ruleset expressions telling the WAF where to find the username and
61 * password in your application's login requests, so credentials submitted
62 * in non-standard payloads can still be checked against breach data.
63 *
64 * Requires Leaked Credential Checks to be **enabled** on the zone (see
65 * {@link LeakedCredentialCheck}) — every detection operation fails with the
66 * typed `LeakedCredentialChecksDisabled` error otherwise. The number of
67 * custom detections is plan-gated (the free plan allows none — creation
68 * fails with the typed `DetectionQuotaExceeded` error).
69 *
70 * Safety: detections carry no ownership markers. When there is no prior
71 * state, `read` scans the zone for an existing detection with the same
72 * expressions and reports it as `Unowned`, so the engine refuses to take
73 * it over unless `--adopt` (or `adopt(true)`) is set.
74 * ### Custom detection locations
75 * **Example:** Detect credentials in a JSON login body
76 * ```typescript
77 * const check = yield* Cloudflare.LeakedCredentialCheck.LeakedCredentialCheck("Lcc", {
78 * zoneId: zone.zoneId,
79 * });
80 *
81 * yield* Cloudflare.LeakedCredentialCheck.LeakedCredentialDetection("LoginBody", {
82 * // Reference the check's zoneId so the toggle deploys first.
83 * zoneId: check.zoneId,
84 * username: 'lookup_json_string(http.request.body.raw, "user")',
85 * password: 'lookup_json_string(http.request.body.raw, "secret")',
86 * });
87 * ```
88 *
89 * **Example:** Username-only detection
90 * ```typescript
91 * yield* Cloudflare.LeakedCredentialCheck.LeakedCredentialDetection("UsernameHeader", {
92 * zoneId: check.zoneId,
93 * username: 'http.request.headers["x-username"][0]',
94 * });
95 * ```
96 *
97 * @see https://developers.cloudflare.com/waf/detections/leaked-credentials/#custom-detection-locations
98 *
99 * @resource
100 * @product Leaked Credential Checks
101 * @category Application Security
102 */
103export const LeakedCredentialDetection =
104 Resource<LeakedCredentialDetection>(TypeId);
105
106/**
107 * Returns true if the given value is a LeakedCredentialDetection resource.
108 */
109export const isLeakedCredentialDetection = (
110 value: unknown,
111): value is LeakedCredentialDetection =>
112 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
113
114export const LeakedCredentialDetectionProvider = () =>
115 Provider.succeed(LeakedCredentialDetection, {
116 stables: ["detectionId", "zoneId"],
117
118 list: Effect.fn(function* () {
119 const { accountId } = yield* yield* CloudflareEnvironment;
120 // Detections live inside a zone (`/zones/{id}/.../detections`) with
121 // no account-wide enumeration API — fan out across every zone and
122 // exhaustively paginate the per-zone list.
123 const zones = yield* listAllZones(accountId);
124 const rows = yield* Effect.forEach(
125 zones,
126 (zone) =>
127 lcc.listDetections.pages({ zoneId: zone.id }).pipe(
128 Stream.runCollect,
129 Effect.map((chunk) =>
130 Array.from(chunk).flatMap((page) =>
131 (page.result ?? []).map(
132 (d): LeakedCredentialDetectionAttributes =>
133 toAttributes(zone.id, d),
134 ),
135 ),
136 ),
137 // Zones without the LCC toggle on refuse all detection reads;
138 // a freshly-minted scoped token can also 403 mid edge-
139 // propagation. Either way the zone contributes nothing.
140 Effect.catchTag(
141 "LeakedCredentialChecksDisabled",
142 (): Effect.Effect<LeakedCredentialDetectionAttributes[]> =>
143 Effect.succeed([]),
144 ),
145 Effect.catchTag(
146 "Forbidden",
147 (): Effect.Effect<LeakedCredentialDetectionAttributes[]> =>
148 Effect.succeed([]),
149 ),
150 ),
151 { concurrency: 10 },
152 );
153 return rows.flat();
154 }),
155
156 diff: Effect.fn(function* ({ olds = {}, news, output }) {
157 const o = olds as LeakedCredentialDetectionProps;
158 const n = news as LeakedCredentialDetectionProps;
159 // zoneId is Input<string>; compare only once both sides are concrete.
160 const oldZoneId =
161 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
162 if (
163 oldZoneId !== undefined &&
164 typeof n.zoneId === "string" &&
165 oldZoneId !== n.zoneId
166 ) {
167 return { action: "replace" } as const;
168 }
169 return undefined;
170 }),
171
172 read: Effect.fn(function* ({ output, olds }) {
173 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
174 if (!zoneId) return undefined;
175
176 // Owned path: refresh by our persisted detection id.
177 if (output?.detectionId) {
178 const observed = yield* getDetection(zoneId, output.detectionId);
179 if (observed) return toAttributes(zoneId, observed);
180 }
181
182 // Adoption path: a detection with these expressions may already
183 // exist on the zone. Detections carry no ownership markers, so we
184 // cannot prove we created it — brand it `Unowned` so the engine
185 // refuses to take over unless `adopt` is set.
186 const username = output?.username ?? olds?.username;
187 const password = output?.password ?? olds?.password;
188 if (username !== undefined || password !== undefined) {
189 const observed = yield* findByExpressions(zoneId, username, password);
190 if (observed) return Unowned(toAttributes(zoneId, observed));
191 }
192 return undefined;
193 }),
194
195 reconcile: Effect.fn(function* ({ news, output }) {
196 // Inputs have been resolved to concrete strings by Plan.
197 const zoneId = news.zoneId as string;
198
199 // 1. Observe — the detection id cached on `output` is a hint, not
200 // a guarantee: a missing detection falls through to the
201 // expression scan and then to create.
202 let observed = output?.detectionId
203 ? yield* getDetection(zoneId, output.detectionId)
204 : undefined;
205
206 // 2. Fall back to scanning the zone for an expression match.
207 // Ownership has already been verified upstream — `read` reports
208 // existing detections as `Unowned` and the engine gates takeover
209 // behind the adopt policy before reconcile ever runs.
210 if (!observed) {
211 observed = yield* findByExpressions(
212 zoneId,
213 news.username,
214 news.password,
215 );
216 }
217
218 // 3. Ensure — create when missing. Requires the zone's Leaked
219 // Credential Checks toggle to be on; otherwise this fails with
220 // the typed `LeakedCredentialChecksDisabled` error.
221 if (!observed) {
222 observed = yield* lcc.createDetection({
223 zoneId,
224 username: news.username,
225 password: news.password,
226 });
227 }
228
229 // 4. Sync — PUT only when the observed expressions differ.
230 const dirty =
231 (observed.username ?? undefined) !== news.username ||
232 (observed.password ?? undefined) !== news.password;
233 if (dirty) {
234 observed = yield* lcc.updateDetection({
235 zoneId,
236 detectionId: observed.id ?? "",
237 username: news.username,
238 password: news.password,
239 });
240 }
241
242 return toAttributes(zoneId, observed);
243 }),
244
245 delete: Effect.fn(function* ({ output }) {
246 yield* lcc
247 .deleteDetection({
248 zoneId: output.zoneId,
249 detectionId: output.detectionId,
250 })
251 .pipe(
252 // Already gone — idempotent re-delete after a crashed run.
253 Effect.catchTag("DetectionNotFound", () => Effect.void),
254 // The zone's toggle was switched off out-of-band; the API
255 // refuses all detection operations then. The detection is
256 // unreachable either way — treat as converged.
257 Effect.catchTag("LeakedCredentialChecksDisabled", () => Effect.void),
258 );
259 }),
260 });
261
262// ---------------------------------------------------------------------------
263// API helpers
264// ---------------------------------------------------------------------------
265
266type ObservedDetection = {
267 id?: string | null;
268 username?: string | null;
269 password?: string | null;
270};
271
272/**
273 * Read a detection by id, mapping "gone" (`DetectionNotFound`, Cloudflare
274 * error code 11002) and "product off" (`LeakedCredentialChecksDisabled`,
275 * code 11001 — the API refuses all detection reads while the zone toggle is
276 * off) to `undefined`.
277 */
278const getDetection = (zoneId: string, detectionId: string) =>
279 lcc.getDetection({ zoneId, detectionId }).pipe(
280 Effect.map((d): ObservedDetection | undefined => d),
281 Effect.catchTag("DetectionNotFound", () => Effect.succeed(undefined)),
282 Effect.catchTag("LeakedCredentialChecksDisabled", () =>
283 Effect.succeed(undefined),
284 ),
285 );
286
287/**
288 * Find a detection by exact username+password expression pair within the
289 * zone. Used for cold reads (state lost) and as the create fallback.
290 */
291const findByExpressions = (
292 zoneId: string,
293 username: string | undefined,
294 password: string | undefined,
295) =>
296 lcc.listDetections.items({ zoneId }).pipe(
297 Stream.runCollect,
298 Effect.map((chunk) =>
299 Array.from(chunk).find(
300 (d): d is ObservedDetection =>
301 (d.username ?? undefined) === username &&
302 (d.password ?? undefined) === password,
303 ),
304 ),
305 Effect.catchTag("LeakedCredentialChecksDisabled", () =>
306 Effect.succeed(undefined),
307 ),
308 );
309
310const toAttributes = (
311 zoneId: string,
312 detection: ObservedDetection,
313): LeakedCredentialDetectionAttributes => ({
314 detectionId: detection.id ?? "",
315 zoneId,
316 username: detection.username ?? undefined,
317 password: detection.password ?? undefined,
318});