1import type * as WAFV2 from "@distilled.cloud/aws/wafv2";
2import * as wafv2 from "@distilled.cloud/aws/wafv2";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 fetchWafTags,
17 retryAssociatedItem,
18 retryOptimisticLock,
19 syncWafTags,
20 type WafScope,
21 withWafScope,
22} from "./internal.ts";
23
24export interface RegexPatternSetProps {
25 /**
26 * Name of the regex pattern set. Must match `^[\w\-]+$` and be 1-128
27 * characters. Changing the name replaces the regex pattern set.
28 * @default a physical name derived from the app, stage and logical ID
29 */
30 regexPatternSetName?: string;
31 /**
32 * Scope of the regex pattern set — `REGIONAL` (ambient region) or
33 * `CLOUDFRONT` (pinned to `us-east-1`). Must match the scope of the web
34 * ACLs and rule groups that reference it. Changing the scope replaces the
35 * regex pattern set.
36 * @default "REGIONAL"
37 */
38 scope?: WafScope;
39 /**
40 * Regular expressions in the set (1-10 patterns, each up to 200
41 * characters). Mutable — updated in place.
42 */
43 regularExpressions: string[];
44 /**
45 * Description of the regex pattern set.
46 */
47 description?: string;
48 /**
49 * User-defined tags to apply to the regex pattern set.
50 */
51 tags?: Record<string, string>;
52}
53
54export interface RegexPatternSet extends Resource<
55 "AWS.WAFv2.RegexPatternSet",
56 RegexPatternSetProps,
57 {
58 /**
59 * Name of the regex pattern set.
60 */
61 regexPatternSetName: string;
62 /**
63 * WAF-assigned unique ID of the regex pattern set.
64 */
65 regexPatternSetId: string;
66 /**
67 * ARN of the regex pattern set — reference it from a rule's
68 * `RegexPatternSetReferenceStatement`.
69 */
70 regexPatternSetArn: string;
71 /**
72 * Scope the regex pattern set was created in.
73 */
74 scope: WafScope;
75 /**
76 * Current regular expressions in the set.
77 */
78 regularExpressions: string[];
79 },
80 never,
81 Providers
82> {}
83
84/**
85 * An AWS WAFv2 regex pattern set — a named collection of regular expressions
86 * referenced from web ACL and rule group rules via
87 * `RegexPatternSetReferenceStatement`.
88 *
89 * ### Creating Regex Pattern Sets
90 * **Example:** Block Requests Matching Bad Path Patterns
91 * ```typescript
92 * const badPaths = yield* AWS.WAFv2.RegexPatternSet("BadPaths", {
93 * regularExpressions: ["^/wp-admin", "\\.php$"],
94 * });
95 * ```
96 *
97 * **Example:** Reference from a Web ACL Rule
98 * ```typescript
99 * const acl = yield* AWS.WAFv2.WebACL("Firewall", {
100 * rules: [
101 * {
102 * Name: "block-bad-paths",
103 * Priority: 0,
104 * Statement: {
105 * RegexPatternSetReferenceStatement: {
106 * ARN: badPaths.regexPatternSetArn,
107 * FieldToMatch: { UriPath: {} },
108 * TextTransformations: [{ Priority: 0, Type: "NONE" }],
109 * },
110 * },
111 * Action: { Block: {} },
112 * VisibilityConfig: {
113 * SampledRequestsEnabled: true,
114 * CloudWatchMetricsEnabled: true,
115 * MetricName: "block-bad-paths",
116 * },
117 * },
118 * ],
119 * });
120 * ```
121 *
122 * @resource
123 */
124export const RegexPatternSet = Resource<RegexPatternSet>(
125 "AWS.WAFv2.RegexPatternSet",
126);
127
128const defaultScope: WafScope = "REGIONAL";
129
130const toExpressionList = (expressions: readonly string[]): WAFV2.Regex[] =>
131 expressions.map((RegexString) => ({ RegexString }));
132
133const fromExpressionList = (
134 list: readonly WAFV2.Regex[] | undefined,
135): string[] =>
136 (list ?? []).flatMap((regex) =>
137 regex.RegexString !== undefined ? [regex.RegexString] : [],
138 );
139
140const sorted = (values: readonly string[]) =>
141 [...values].sort((a, b) => a.localeCompare(b));
142
143const toAttrs = (set: WAFV2.RegexPatternSet, scope: WafScope) => ({
144 regexPatternSetName: set.Name ?? "",
145 regexPatternSetId: set.Id ?? "",
146 regexPatternSetArn: set.ARN ?? "",
147 scope,
148 regularExpressions: fromExpressionList(set.RegularExpressionList),
149});
150
151export const RegexPatternSetProvider = () =>
152 Provider.effect(
153 RegexPatternSet,
154 Effect.gen(function* () {
155 const createName = Effect.fn(function* (
156 id: string,
157 props: RegexPatternSetProps,
158 ) {
159 return (
160 props.regexPatternSetName ??
161 (yield* createPhysicalName({ id, maxLength: 128 }))
162 );
163 });
164
165 const findSet = Effect.fn(function* (
166 scope: WafScope,
167 name: string,
168 cachedId: string | undefined,
169 ) {
170 if (cachedId !== undefined) {
171 const byId = yield* withWafScope(
172 scope,
173 wafv2
174 .getRegexPatternSet({ Name: name, Scope: scope, Id: cachedId })
175 .pipe(
176 Effect.catchTag("WAFNonexistentItemException", () =>
177 Effect.succeed(undefined),
178 ),
179 ),
180 );
181 if (byId?.RegexPatternSet) {
182 return byId;
183 }
184 }
185 let marker: string | undefined;
186 for (let page = 0; page < 20; page++) {
187 const listed = yield* withWafScope(
188 scope,
189 wafv2.listRegexPatternSets({
190 Scope: scope,
191 NextMarker: marker,
192 Limit: 100,
193 }),
194 );
195 const summary = listed.RegexPatternSets?.find((s) => s.Name === name);
196 if (summary?.Id !== undefined) {
197 return yield* withWafScope(
198 scope,
199 wafv2
200 .getRegexPatternSet({
201 Name: name,
202 Scope: scope,
203 Id: summary.Id,
204 })
205 .pipe(
206 Effect.catchTag("WAFNonexistentItemException", () =>
207 Effect.succeed(undefined),
208 ),
209 ),
210 );
211 }
212 if (
213 !listed.NextMarker ||
214 (listed.RegexPatternSets?.length ?? 0) === 0
215 ) {
216 break;
217 }
218 marker = listed.NextMarker;
219 }
220 return undefined;
221 });
222
223 const listScope = Effect.fn(function* (scope: WafScope) {
224 const rows: ReturnType<typeof toAttrs>[] = [];
225 let marker: string | undefined;
226 for (let page = 0; page < 50; page++) {
227 const listed = yield* withWafScope(
228 scope,
229 wafv2.listRegexPatternSets({
230 Scope: scope,
231 NextMarker: marker,
232 Limit: 100,
233 }),
234 );
235 const summaries = listed.RegexPatternSets ?? [];
236 const details = yield* Effect.forEach(
237 summaries,
238 (summary) =>
239 summary.Name !== undefined && summary.Id !== undefined
240 ? withWafScope(
241 scope,
242 wafv2
243 .getRegexPatternSet({
244 Name: summary.Name,
245 Scope: scope,
246 Id: summary.Id,
247 })
248 .pipe(
249 Effect.catchTag("WAFNonexistentItemException", () =>
250 Effect.succeed(undefined),
251 ),
252 ),
253 )
254 : Effect.succeed(undefined),
255 { concurrency: 5 },
256 );
257 for (const detail of details) {
258 if (detail?.RegexPatternSet) {
259 rows.push(toAttrs(detail.RegexPatternSet, scope));
260 }
261 }
262 if (!listed.NextMarker || summaries.length === 0) {
263 break;
264 }
265 marker = listed.NextMarker;
266 }
267 return rows;
268 });
269
270 return {
271 stables: [
272 "regexPatternSetName",
273 "regexPatternSetId",
274 "regexPatternSetArn",
275 "scope",
276 ],
277
278 list: () =>
279 Effect.gen(function* () {
280 const regional = yield* listScope("REGIONAL");
281 const cloudfront = yield* listScope("CLOUDFRONT");
282 return [...regional, ...cloudfront];
283 }),
284
285 diff: Effect.fn(function* ({ id, news, olds }) {
286 if (!isResolved(news)) return undefined;
287 const oldName = yield* createName(
288 id,
289 olds ?? { regularExpressions: [] },
290 );
291 const newName = yield* createName(id, news);
292 if (
293 oldName !== newName ||
294 (olds?.scope ?? defaultScope) !== (news.scope ?? defaultScope)
295 ) {
296 return { action: "replace" } as const;
297 }
298 }),
299
300 read: Effect.fn(function* ({ id, olds, output }) {
301 const scope = output?.scope ?? olds?.scope ?? defaultScope;
302 const name =
303 output?.regexPatternSetName ??
304 (yield* createName(id, olds ?? { regularExpressions: [] }));
305 const found = yield* findSet(scope, name, output?.regexPatternSetId);
306 if (!found?.RegexPatternSet?.ARN) {
307 return undefined;
308 }
309 const attrs = toAttrs(found.RegexPatternSet, scope);
310 const tags = yield* fetchWafTags(scope, found.RegexPatternSet.ARN);
311 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
312 }),
313
314 reconcile: Effect.fn(function* ({ id, news, output, session }) {
315 const scope = news.scope ?? defaultScope;
316 const name =
317 output?.regexPatternSetName ?? (yield* createName(id, news));
318 const internalTags = yield* createInternalTags(id);
319 const desiredTags = { ...news.tags, ...internalTags };
320
321 // 1. Observe.
322 let observed = yield* findSet(scope, name, output?.regexPatternSetId);
323
324 // 2. Ensure — tolerate a duplicate-create race and re-read.
325 if (!observed?.RegexPatternSet) {
326 yield* withWafScope(
327 scope,
328 wafv2
329 .createRegexPatternSet({
330 Name: name,
331 Scope: scope,
332 RegularExpressionList: toExpressionList(
333 news.regularExpressions,
334 ),
335 Description: news.description,
336 Tags: createTagsList(desiredTags),
337 })
338 .pipe(
339 Effect.catchTag("WAFDuplicateItemException", () =>
340 Effect.succeed(undefined),
341 ),
342 ),
343 );
344 observed = yield* findSet(scope, name, undefined);
345 }
346
347 const set = observed?.RegexPatternSet;
348 const arn = set?.ARN;
349 if (set === undefined || arn === undefined) {
350 return yield* Effect.fail(
351 new Error(
352 `Failed to observe RegexPatternSet '${name}' after create`,
353 ),
354 );
355 }
356
357 yield* session.note(arn);
358
359 // 3. Sync expressions + description against OBSERVED state. WAF
360 // may return the list in arbitrary order — compare as sets.
361 const drifted = !deepEqual(
362 {
363 RegularExpressions: sorted(
364 fromExpressionList(set.RegularExpressionList),
365 ),
366 Description: set.Description,
367 },
368 {
369 RegularExpressions: sorted(news.regularExpressions),
370 Description: news.description,
371 },
372 { stripNullish: true },
373 );
374 if (drifted) {
375 yield* retryOptimisticLock(
376 Effect.gen(function* () {
377 const fresh = yield* findSet(scope, name, set.Id);
378 if (
379 !fresh?.RegexPatternSet?.Id ||
380 fresh.LockToken === undefined
381 ) {
382 return;
383 }
384 yield* withWafScope(
385 scope,
386 wafv2.updateRegexPatternSet({
387 Name: name,
388 Scope: scope,
389 Id: fresh.RegexPatternSet.Id,
390 RegularExpressionList: toExpressionList(
391 news.regularExpressions,
392 ),
393 Description: news.description,
394 LockToken: fresh.LockToken,
395 }),
396 );
397 }),
398 );
399 }
400
401 // 3b. Sync tags against OBSERVED cloud tags.
402 yield* syncWafTags(scope, arn, desiredTags);
403
404 // 4. Return fresh attributes.
405 return {
406 ...toAttrs(set, scope),
407 regularExpressions: [...news.regularExpressions],
408 };
409 }),
410
411 delete: Effect.fn(function* ({ output }) {
412 const scope = output.scope;
413 // A pattern set still referenced by a web ACL rule (deletion
414 // propagation) surfaces WAFAssociatedItemException — retry it.
415 yield* retryAssociatedItem(
416 retryOptimisticLock(
417 Effect.gen(function* () {
418 const found = yield* withWafScope(
419 scope,
420 wafv2
421 .getRegexPatternSet({
422 Name: output.regexPatternSetName,
423 Scope: scope,
424 Id: output.regexPatternSetId,
425 })
426 .pipe(
427 Effect.catchTag("WAFNonexistentItemException", () =>
428 Effect.succeed(undefined),
429 ),
430 ),
431 );
432 if (!found?.RegexPatternSet || found.LockToken === undefined) {
433 return;
434 }
435 yield* withWafScope(
436 scope,
437 wafv2
438 .deleteRegexPatternSet({
439 Name: output.regexPatternSetName,
440 Scope: scope,
441 Id: output.regexPatternSetId,
442 LockToken: found.LockToken,
443 })
444 .pipe(
445 Effect.catchTag(
446 "WAFNonexistentItemException",
447 () => Effect.void,
448 ),
449 ),
450 );
451 }),
452 ),
453 );
454 }),
455 };
456 }),
457 );