| 1 | import type * as WAFV2 from "@distilled.cloud/aws/wafv2"; |
| 2 | import * as wafv2 from "@distilled.cloud/aws/wafv2"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | import { type WafScope, withWafScope } from "./internal.ts"; |
| 10 | |
| 11 | export interface LoggingConfigurationProps { |
| 12 | /** |
| 13 | * ARN of the {@link WebACL} to enable logging for. Changing the web ACL |
| 14 | * replaces the logging configuration. |
| 15 | */ |
| 16 | resourceArn: string; |
| 17 | /** |
| 18 | * ARNs of the log destinations — an Amazon Kinesis Data Firehose delivery |
| 19 | * stream, a CloudWatch Logs log group, or an S3 bucket. The destination |
| 20 | * name must begin with `aws-waf-logs-`. A trailing `:*` on a CloudWatch |
| 21 | * Logs log group ARN is stripped automatically (WAF rejects it). |
| 22 | */ |
| 23 | logDestinationConfigs: string[]; |
| 24 | /** |
| 25 | * Parts of each logged request to redact (e.g. a specific header or the |
| 26 | * query string). Only `SingleHeader`, `UriPath`, `QueryString`, and |
| 27 | * `Method` are supported by WAF. Raw WAFv2 API shapes. |
| 28 | */ |
| 29 | redactedFields?: WAFV2.FieldToMatch[]; |
| 30 | /** |
| 31 | * Filter which requests are logged based on rule action and labels. |
| 32 | */ |
| 33 | loggingFilter?: WAFV2.LoggingFilter; |
| 34 | } |
| 35 | |
| 36 | export interface LoggingConfiguration extends Resource< |
| 37 | "AWS.WAFv2.LoggingConfiguration", |
| 38 | LoggingConfigurationProps, |
| 39 | { |
| 40 | /** |
| 41 | * ARN of the web ACL the logging configuration applies to. |
| 42 | */ |
| 43 | resourceArn: string; |
| 44 | /** |
| 45 | * Scope of the web ACL (derived from its ARN). |
| 46 | */ |
| 47 | scope: WafScope; |
| 48 | /** |
| 49 | * ARNs of the configured log destinations. |
| 50 | */ |
| 51 | logDestinationConfigs: string[]; |
| 52 | }, |
| 53 | never, |
| 54 | Providers |
| 55 | > {} |
| 56 | |
| 57 | /** |
| 58 | * The logging configuration of an AWS WAFv2 {@link WebACL} — streams full |
| 59 | * web request logs to a Kinesis Data Firehose delivery stream, a CloudWatch |
| 60 | * Logs log group, or an S3 bucket. |
| 61 | * |
| 62 | * The destination must be named with the `aws-waf-logs-` prefix. A web ACL |
| 63 | * has at most one logging configuration; deleting this resource disables |
| 64 | * logging. |
| 65 | * |
| 66 | * ### Configuring Logging |
| 67 | * **Example:** Log to CloudWatch Logs |
| 68 | * ```typescript |
| 69 | * const logGroup = yield* AWS.Logs.LogGroup("WafLogs", { |
| 70 | * logGroupName: "aws-waf-logs-my-firewall", |
| 71 | * }); |
| 72 | * |
| 73 | * yield* AWS.WAFv2.LoggingConfiguration("Logging", { |
| 74 | * resourceArn: acl.webAclArn, |
| 75 | * logDestinationConfigs: [logGroup.logGroupArn], |
| 76 | * }); |
| 77 | * ``` |
| 78 | * |
| 79 | * **Example:** Redact Headers and Filter to Blocked Requests |
| 80 | * ```typescript |
| 81 | * yield* AWS.WAFv2.LoggingConfiguration("Logging", { |
| 82 | * resourceArn: acl.webAclArn, |
| 83 | * logDestinationConfigs: [logGroup.logGroupArn], |
| 84 | * redactedFields: [{ SingleHeader: { Name: "authorization" } }], |
| 85 | * loggingFilter: { |
| 86 | * DefaultBehavior: "DROP", |
| 87 | * Filters: [ |
| 88 | * { |
| 89 | * Behavior: "KEEP", |
| 90 | * Requirement: "MEETS_ANY", |
| 91 | * Conditions: [{ ActionCondition: { Action: "BLOCK" } }], |
| 92 | * }, |
| 93 | * ], |
| 94 | * }, |
| 95 | * }); |
| 96 | * ``` |
| 97 | * |
| 98 | * @resource |
| 99 | */ |
| 100 | export const LoggingConfiguration = Resource<LoggingConfiguration>( |
| 101 | "AWS.WAFv2.LoggingConfiguration", |
| 102 | ); |
| 103 | |
| 104 | /** |
| 105 | * Derive the scope from a WAFv2 entity ARN. CLOUDFRONT-scoped ARNs carry a |
| 106 | * `global/` resource prefix (`arn:aws:wafv2:us-east-1:acct:global/webacl/…`); |
| 107 | * REGIONAL ARNs carry `regional/`. |
| 108 | */ |
| 109 | const scopeOfArn = (arn: string): WafScope => |
| 110 | arn.split(":")[5]?.startsWith("global/") ? "CLOUDFRONT" : "REGIONAL"; |
| 111 | |
| 112 | /** |
| 113 | * WAF rejects CloudWatch Logs log group ARNs with the `:*` suffix that IAM |
| 114 | * policies (and the Logs API) use — strip it. |
| 115 | */ |
| 116 | const normalizeDestination = (arn: string): string => |
| 117 | arn.endsWith(":*") ? arn.slice(0, -2) : arn; |
| 118 | |
| 119 | /** |
| 120 | * The first PutLoggingConfiguration in an account creates the WAF |
| 121 | * service-linked role; until that completes the API surfaces |
| 122 | * `WAFServiceLinkedRoleErrorException` ("Retry your request"). Bounded |
| 123 | * retry (~30s). |
| 124 | */ |
| 125 | const retryServiceLinkedRole = <A, E extends { _tag: string }, R>( |
| 126 | self: Effect.Effect<A, E, R>, |
| 127 | ): Effect.Effect<A, E, R> => |
| 128 | Effect.retry(self, { |
| 129 | while: (e) => e._tag === "WAFServiceLinkedRoleErrorException", |
| 130 | schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]), |
| 131 | }); |
| 132 | |
| 133 | export const LoggingConfigurationProvider = () => |
| 134 | Provider.effect( |
| 135 | LoggingConfiguration, |
| 136 | Effect.gen(function* () { |
| 137 | const readConfig = Effect.fn(function* (resourceArn: string) { |
| 138 | const scope = scopeOfArn(resourceArn); |
| 139 | return yield* withWafScope( |
| 140 | scope, |
| 141 | wafv2 |
| 142 | .getLoggingConfiguration({ ResourceArn: resourceArn }) |
| 143 | .pipe( |
| 144 | Effect.catchTag("WAFNonexistentItemException", () => |
| 145 | Effect.succeed(undefined), |
| 146 | ), |
| 147 | ), |
| 148 | ); |
| 149 | }); |
| 150 | |
| 151 | const toAttrs = ( |
| 152 | resourceArn: string, |
| 153 | destinations: readonly string[], |
| 154 | ) => ({ |
| 155 | resourceArn, |
| 156 | scope: scopeOfArn(resourceArn), |
| 157 | logDestinationConfigs: [...destinations], |
| 158 | }); |
| 159 | |
| 160 | return LoggingConfiguration.Provider.of({ |
| 161 | stables: ["resourceArn", "scope"], |
| 162 | |
| 163 | // A logging configuration is a singleton setting keyed by its web |
| 164 | // ACL; enumerating them standalone is not useful for orphan scans |
| 165 | // (the web ACL itself is the discoverable resource). |
| 166 | list: () => Effect.succeed([]), |
| 167 | |
| 168 | diff: Effect.fn(function* ({ news, olds }) { |
| 169 | if (!isResolved(news)) return undefined; |
| 170 | if (olds.resourceArn !== news.resourceArn) { |
| 171 | return { action: "replace" } as const; |
| 172 | } |
| 173 | }), |
| 174 | |
| 175 | read: Effect.fn(function* ({ olds, output }) { |
| 176 | const resourceArn = output?.resourceArn ?? olds?.resourceArn; |
| 177 | if (resourceArn === undefined) return undefined; |
| 178 | const found = yield* readConfig(resourceArn); |
| 179 | const config = found?.LoggingConfiguration; |
| 180 | if (!config) return undefined; |
| 181 | return toAttrs(resourceArn, config.LogDestinationConfigs ?? []); |
| 182 | }), |
| 183 | |
| 184 | reconcile: Effect.fn(function* ({ news, session }) { |
| 185 | const scope = scopeOfArn(news.resourceArn); |
| 186 | const desired: WAFV2.LoggingConfiguration = { |
| 187 | ResourceArn: news.resourceArn, |
| 188 | LogDestinationConfigs: |
| 189 | news.logDestinationConfigs.map(normalizeDestination), |
| 190 | RedactedFields: news.redactedFields, |
| 191 | LoggingFilter: news.loggingFilter, |
| 192 | }; |
| 193 | |
| 194 | // Observe — PutLoggingConfiguration is a true upsert, so only |
| 195 | // call it when the observed configuration drifts from desired. |
| 196 | const observed = yield* readConfig(news.resourceArn); |
| 197 | const current = observed?.LoggingConfiguration; |
| 198 | const drifted = !deepEqual( |
| 199 | { |
| 200 | LogDestinationConfigs: current?.LogDestinationConfigs ?? [], |
| 201 | RedactedFields: current?.RedactedFields, |
| 202 | LoggingFilter: current?.LoggingFilter, |
| 203 | }, |
| 204 | { |
| 205 | LogDestinationConfigs: desired.LogDestinationConfigs, |
| 206 | RedactedFields: desired.RedactedFields, |
| 207 | LoggingFilter: desired.LoggingFilter, |
| 208 | }, |
| 209 | { stripNullish: true }, |
| 210 | ); |
| 211 | if (current === undefined || drifted) { |
| 212 | yield* retryServiceLinkedRole( |
| 213 | withWafScope( |
| 214 | scope, |
| 215 | wafv2.putLoggingConfiguration({ |
| 216 | LoggingConfiguration: desired, |
| 217 | }), |
| 218 | ), |
| 219 | ); |
| 220 | } |
| 221 | |
| 222 | yield* session.note(news.resourceArn); |
| 223 | return toAttrs(news.resourceArn, desired.LogDestinationConfigs); |
| 224 | }), |
| 225 | |
| 226 | delete: Effect.fn(function* ({ output }) { |
| 227 | const scope = scopeOfArn(output.resourceArn); |
| 228 | // Idempotent — the configuration (or the web ACL itself) may |
| 229 | // already be gone. |
| 230 | yield* withWafScope( |
| 231 | scope, |
| 232 | wafv2 |
| 233 | .deleteLoggingConfiguration({ |
| 234 | ResourceArn: output.resourceArn, |
| 235 | }) |
| 236 | .pipe( |
| 237 | Effect.catchTag( |
| 238 | "WAFNonexistentItemException", |
| 239 | () => Effect.void, |
| 240 | ), |
| 241 | ), |
| 242 | ); |
| 243 | }), |
| 244 | }); |
| 245 | }), |
| 246 | ); |