From 760b8c5dfd87d4131668a6e21b40c03bcbcf3e7e Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 12:15:51 +1000 Subject: [PATCH 01/11] feat(waf): env-level `waf: true` provisions a managed WAF on the shared ALB (LPX-673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front the environment load balancer with a YOLO-managed regional WAFv2 web ACL. Env-scoped, so one ACL protects every app on the ALB; written by sync:environment, off by default, purely additive once enabled. YOLO owns the policy skeleton and reconciles it; the operator owns the list contents, which sync never touches: - Default action Allow, then allow/block IP-set rules, the AWS managed groups (IP reputation + known-bad-inputs Block; CRS + SQLi Count-first), and a ~2000/5min per-IP rate limit. Managed groups referenced unversioned so AWS signature/IP-rep updates roll in on their own. - IP sets are create-only (seeded empty) — an operator's mid-incident console edits survive every sync. Hand-added rules (matched by name) are preserved through reconciling updates too; YOLO only rewrites the rules it owns. New: src/Aws/WafV2.php wrapper + wafV2 client + synchroniseWafV2Tags; WebAcl (SynchronisesConfiguration) + Allow/Block IpSet resources; 4 Sync/Environment steps; ExecutesWafStep gate on Manifest::wafEnabled(); `waf` manifest key; 'WafV2' => 'wafv2' audit catalogue entry; manifest + commands docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/commands.md | 2 +- docs/reference/manifest.md | 33 ++ src/Audit/Audit.php | 1 + src/Aws.php | 25 ++ src/Aws/WafV2.php | 73 ++++ src/Commands/SyncEnvironmentCommand.php | 7 + .../ChecksIfCommandsShouldBeRunning.php | 5 + src/Concerns/RegistersAws.php | 2 + src/Contracts/ExecutesWafStep.php | 7 + src/Manifest.php | 11 + src/Resources/WafV2/AllowIpSet.php | 24 ++ src/Resources/WafV2/BlockIpSet.php | 24 ++ src/Resources/WafV2/IpSet.php | 68 ++++ src/Resources/WafV2/WebAcl.php | 348 ++++++++++++++++++ .../Environment/SyncWafAllowIpSetStep.php | 18 + .../Environment/SyncWafAssociationStep.php | 52 +++ .../Environment/SyncWafBlockIpSetStep.php | 18 + .../Sync/Environment/SyncWafWebAclStep.php | 18 + tests/Pest.php | 43 +++ .../Unit/Aws/ResourceGroupsTaggingApiTest.php | 14 +- tests/Unit/Concerns/SkipReasonTest.php | 22 ++ tests/Unit/ManifestTest.php | 26 ++ tests/Unit/Resources/WafV2/IpSetTest.php | 88 +++++ tests/Unit/Resources/WafV2/WebAclTest.php | 192 ++++++++++ tests/Unit/Steps/Waf/WafStepsTest.php | 87 +++++ 25 files changed, 1200 insertions(+), 8 deletions(-) create mode 100644 src/Aws/WafV2.php create mode 100644 src/Contracts/ExecutesWafStep.php create mode 100644 src/Resources/WafV2/AllowIpSet.php create mode 100644 src/Resources/WafV2/BlockIpSet.php create mode 100644 src/Resources/WafV2/IpSet.php create mode 100644 src/Resources/WafV2/WebAcl.php create mode 100644 src/Steps/Sync/Environment/SyncWafAllowIpSetStep.php create mode 100644 src/Steps/Sync/Environment/SyncWafAssociationStep.php create mode 100644 src/Steps/Sync/Environment/SyncWafBlockIpSetStep.php create mode 100644 src/Steps/Sync/Environment/SyncWafWebAclStep.php create mode 100644 tests/Unit/Resources/WafV2/IpSetTest.php create mode 100644 tests/Unit/Resources/WafV2/WebAclTest.php create mode 100644 tests/Unit/Steps/Waf/WafStepsTest.php diff --git a/docs/reference/commands.md b/docs/reference/commands.md index e51f1522..6b6ca42a 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -276,7 +276,7 @@ Arguments and options as [`sync`](#sync-options). Scope: **account**. ## `yolo sync:environment` -Sync the environment-shared (environment-tier) resources — VPC, subnets, internet gateway and routes, the load balancer security group, the ALB and its `:80` listener, the SNS alarm topic, and the shared ECS execution IAM role. +Sync the environment-shared (environment-tier) resources — VPC, subnets, internet gateway and routes, the load balancer security group, the ALB and its `:80` listener, the SNS alarm topic, the shared ECS execution IAM role, and — when [`waf: true`](/reference/manifest#waf) — the WAF web ACL and its allow/block IP sets fronting the ALB. ```bash yolo sync:environment [--check] [--force] [--no-progress] [--tenant=] diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md index 019bb221..46df1f6b 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -49,6 +49,9 @@ environments: # log-retention-days: 30 # default: 14 — CloudWatch retention # mediaconvert: arn:aws:iam::123456789012:role/MediaConvertRole # transcoding role ARN (used with IVS) + # --- Security --- + # waf: true # front the env load balancer with a managed WAF web ACL (env-scoped, off by default) + # --- Extra IAM for this app's task role (per-app; never reaches another app) --- # task-role-policies: # - arn:aws:iam::123456789012:policy/my-app-extra-access @@ -241,6 +244,36 @@ ivs: MediaConvert role ARN for video transcoding workloads (used with IVS). +### `waf` + +Fronts the environment load balancer with a YOLO-managed [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html) web ACL. Set to `true`: + +```yaml +waf: true +``` + +It's **environment scoped** — one web ACL protects every app sharing the ALB — so it's written by [`yolo sync:environment`](/reference/commands#yolo-sync-environment), not `sync:app`. Off by default; provisioning is purely additive once on. + +YOLO owns the **policy skeleton** and reconciles it on every sync; you own the **list contents**, which sync never touches: + +| YOLO reconciles (declarative) | You manage (console, never reconciled) | +| --- | --- | +| Default action (`Allow`), the allow/block rule wiring, the AWS managed rule groups and their actions, the per-IP rate limit | The CIDRs inside the allow / block IP sets, and any rule you add by hand | + +The default skeleton, in priority order: + +| Rule | Action | Notes | +| --- | --- | --- | +| Allow IP set | Allow | Seeded **empty** — add known-good IPs (e.g. crawler ranges a managed group might false-positive). | +| Block IP set | Block | Seeded **empty** — the lever for shutting down an abusive source mid-incident. | +| Amazon IP reputation list | Block | Low false-positive; auto-evolves. | +| Known bad inputs | Block | Low false-positive; auto-evolves. | +| Core rule set (CRS) | **Count** | Ships in Count so a new AWS signature can't start blocking live traffic unannounced — promote to Block once you've watched the metrics. | +| SQL injection | **Count** | Same Count-first treatment. | +| Rate limit | Block | ~2000 requests / 5 min **per source IP**. | + +The managed groups are referenced **unversioned**, so AWS's signature and IP-reputation updates roll in automatically — the WAF gets better over time without a YOLO change. The IP sets are **create-only**: an IP you add in the console survives every subsequent `sync`. A rule you add by hand (matched by name) is preserved too — YOLO only ever rewrites the rules it owns. `waf` needs a web/ALB environment; it has no effect on a headless app. + ### `task-role-policies` Extra IAM policy ARNs to attach to this app's ECS **task role** — the runtime identity its containers (web, queue and scheduler) assume. YOLO gives every app its own task role, so these grants reach only this app and never another. This is how you let your container call an AWS service YOLO doesn't wire for you (an extra S3 bucket, DynamoDB, Bedrock, …): the role carries the access, so the app authenticates as itself with no credentials to manage. diff --git a/src/Audit/Audit.php b/src/Audit/Audit.php index 70877c43..eaab891c 100644 --- a/src/Audit/Audit.php +++ b/src/Audit/Audit.php @@ -79,6 +79,7 @@ class Audit 'S3' => 's3', 'Sns' => 'sns', 'Sqs' => 'sqs', + 'WafV2' => 'wafv2', ]; /** diff --git a/src/Aws.php b/src/Aws.php index 499b3f91..fcfd73bd 100644 --- a/src/Aws.php +++ b/src/Aws.php @@ -13,6 +13,7 @@ use Aws\Sqs\SqsClient; use Aws\Ssm\SsmClient; use Aws\Sts\StsClient; +use Aws\WAFV2\WAFV2Client; use Aws\AwsClientInterface; use Illuminate\Support\Str; use Aws\Route53\Route53Client; @@ -505,6 +506,25 @@ public static function synchroniseCloudFrontTags(string $arn, array $tags, bool ); } + /** + * Synchronise tags on a WAFv2 resource (web ACL, IP set), addressed by its + * ARN. WAFv2 nests the live tags under TagInfoForResource.TagList. + * + * @return array + */ + public static function synchroniseWafV2Tags(string $arn, array $tags, bool $apply): array + { + return static::reconcileTags( + $tags, + fn () => static::wafV2()->listTagsForResource(['ResourceARN' => $arn])['TagInfoForResource']['TagList'] ?? [], + fn (array $missing) => static::wafV2()->tagResource([ + 'ResourceARN' => $arn, + 'Tags' => static::keyValueTags($missing), + ]), + $apply, + ); + } + /** * The standard upper-case `[{Key, Value}]` tag-list shape most AWS tagging * APIs accept on write, built from an associative {key => value} map. @@ -682,4 +702,9 @@ public static function sts(): StsClient { return Helpers::app('sts'); } + + public static function wafV2(): WAFV2Client + { + return Helpers::app('wafV2'); + } } diff --git a/src/Aws/WafV2.php b/src/Aws/WafV2.php new file mode 100644 index 00000000..a1621b98 --- /dev/null +++ b/src/Aws/WafV2.php @@ -0,0 +1,73 @@ + + */ + public static function webAcl(string $name): array + { + return static::findByName('listWebACLs', 'WebACLs', $name) + ?? throw new ResourceDoesNotExistException("Could not find WAF web ACL $name"); + } + + /** + * The IPSet summary {Name, Id, ARN, LockToken} for the given name. + * + * @return array + */ + public static function ipSet(string $name): array + { + return static::findByName('listIPSets', 'IPSets', $name) + ?? throw new ResourceDoesNotExistException("Could not find WAF IP set $name"); + } + + /** + * Page through a WAFv2 list operation and return the first summary whose + * Name matches, or null. WAFv2 list pages are capped, so NextMarker is + * followed to completion. + * + * @return array|null + */ + protected static function findByName(string $operation, string $key, string $name): ?array + { + $marker = null; + + do { + $response = Aws::wafV2()->{$operation}(array_filter([ + 'Scope' => static::SCOPE, + 'NextMarker' => $marker, + ])); + + foreach ($response[$key] ?? [] as $summary) { + if ($summary['Name'] === $name) { + return $summary; + } + } + + $marker = $response['NextMarker'] ?? null; + } while ($marker !== null && ($response[$key] ?? []) !== []); + + return null; + } +} diff --git a/src/Commands/SyncEnvironmentCommand.php b/src/Commands/SyncEnvironmentCommand.php index 0ad78221..a1ea3e9b 100644 --- a/src/Commands/SyncEnvironmentCommand.php +++ b/src/Commands/SyncEnvironmentCommand.php @@ -52,6 +52,13 @@ public function scopes(): array // load balancer + :80 listener Steps\Sync\Environment\SyncLoadBalancerStep::class, Steps\Sync\Environment\SyncHttpListenerStep::class, + // WAF (opt-in via `waf: true`) — the IP sets are referenced by the + // web ACL's rules, so they're created first; the ACL is then bound + // to the load balancer. Inert unless the manifest enables it. + Steps\Sync\Environment\SyncWafAllowIpSetStep::class, + Steps\Sync\Environment\SyncWafBlockIpSetStep::class, + Steps\Sync\Environment\SyncWafWebAclStep::class, + Steps\Sync\Environment\SyncWafAssociationStep::class, ], ]; } diff --git a/src/Concerns/ChecksIfCommandsShouldBeRunning.php b/src/Concerns/ChecksIfCommandsShouldBeRunning.php index b44fb343..15683d56 100644 --- a/src/Concerns/ChecksIfCommandsShouldBeRunning.php +++ b/src/Concerns/ChecksIfCommandsShouldBeRunning.php @@ -8,6 +8,7 @@ use Codinglabs\Yolo\Commands\Command; use Codinglabs\Yolo\Contracts\RunsOnAws; use Codinglabs\Yolo\Contracts\ExecutesIvsStep; +use Codinglabs\Yolo\Contracts\ExecutesWafStep; use Codinglabs\Yolo\Contracts\ExecutesWebStep; use Codinglabs\Yolo\Contracts\ExecutesSoloStep; use Codinglabs\Yolo\Contracts\ExecutesMultitenancyStep; @@ -40,6 +41,10 @@ public function skipReason(Command|Step $instance): ?string return 'ivs not enabled in manifest'; } + if ($instance instanceof ExecutesWafStep && ! Manifest::wafEnabled()) { + return 'waf not enabled in manifest'; + } + if (Aws::runningInAws()) { return $instance instanceof RunsOnAws ? null : 'does not run on AWS instances'; } diff --git a/src/Concerns/RegistersAws.php b/src/Concerns/RegistersAws.php index f443ddb8..da44f862 100644 --- a/src/Concerns/RegistersAws.php +++ b/src/Concerns/RegistersAws.php @@ -15,6 +15,7 @@ use Aws\Sts\StsClient; use GuzzleHttp\Client; use Codinglabs\Yolo\Aws; +use Aws\WAFV2\WAFV2Client; use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; use Aws\Route53\Route53Client; @@ -68,6 +69,7 @@ protected function registerAwsServices(): void Helpers::app()->singleton('sqs', fn (): SqsClient => new SqsClient($arguments)); Helpers::app()->singleton('ssm', fn (): SsmClient => new SsmClient($arguments)); Helpers::app()->singleton('sts', fn (): StsClient => new StsClient($arguments)); + Helpers::app()->singleton('wafV2', fn (): WAFV2Client => new WAFV2Client($arguments)); } protected static function awsCredentials(): callable|array|null diff --git a/src/Contracts/ExecutesWafStep.php b/src/Contracts/ExecutesWafStep.php new file mode 100644 index 00000000..ba57292a --- /dev/null +++ b/src/Contracts/ExecutesWafStep.php @@ -0,0 +1,7 @@ +> */ diff --git a/src/Resources/WafV2/AllowIpSet.php b/src/Resources/WafV2/AllowIpSet.php new file mode 100644 index 00000000..08516c51 --- /dev/null +++ b/src/Resources/WafV2/AllowIpSet.php @@ -0,0 +1,24 @@ +keyedName('waf-allow'); + } + + protected function description(): string + { + return 'YOLO WAF allow list — known-good IPs (human-managed, never reconciled)'; + } +} diff --git a/src/Resources/WafV2/BlockIpSet.php b/src/Resources/WafV2/BlockIpSet.php new file mode 100644 index 00000000..c6d4aa91 --- /dev/null +++ b/src/Resources/WafV2/BlockIpSet.php @@ -0,0 +1,24 @@ +keyedName('waf-block'); + } + + protected function description(): string + { + return 'YOLO WAF block list (human-managed, never reconciled)'; + } +} diff --git a/src/Resources/WafV2/IpSet.php b/src/Resources/WafV2/IpSet.php new file mode 100644 index 00000000..c6d90fde --- /dev/null +++ b/src/Resources/WafV2/IpSet.php @@ -0,0 +1,68 @@ +name()); + + return true; + } catch (ResourceDoesNotExistException) { + return false; + } + } + + public function arn(): string + { + return WafV2::ipSet($this->name())['ARN']; + } + + public function create(): void + { + Aws::wafV2()->createIPSet([ + 'Name' => $this->name(), + 'Scope' => WafV2::SCOPE, + 'Description' => $this->description(), + 'IPAddressVersion' => 'IPV4', + 'Addresses' => [], + ...Aws::tags($this->tags()), + ]); + } + + public function synchroniseTags(bool $apply): array + { + return Aws::synchroniseWafV2Tags($this->arn(), $this->tags(), $apply); + } +} diff --git a/src/Resources/WafV2/WebAcl.php b/src/Resources/WafV2/WebAcl.php new file mode 100644 index 00000000..173b3f34 --- /dev/null +++ b/src/Resources/WafV2/WebAcl.php @@ -0,0 +1,348 @@ +keyedName('waf'); + } + + public function scope(): Scope + { + return Scope::Env; + } + + public function exists(): bool + { + try { + WafV2::webAcl($this->name()); + + return true; + } catch (ResourceDoesNotExistException) { + return false; + } + } + + public function arn(): string + { + return WafV2::webAcl($this->name())['ARN']; + } + + public function create(): void + { + Aws::wafV2()->createWebACL([ + 'Name' => $this->name(), + 'Scope' => WafV2::SCOPE, + 'Description' => 'YOLO managed WAF for the environment load balancer', + 'DefaultAction' => $this->defaultAction(), + 'Rules' => $this->desiredRules(), + 'VisibilityConfig' => $this->visibilityConfig($this->name()), + ...Aws::tags($this->tags()), + ]); + } + + public function synchroniseTags(bool $apply): array + { + return Aws::synchroniseWafV2Tags($this->arn(), $this->tags(), $apply); + } + + /** + * Reconcile the policy skeleton onto the live ACL. Drift is computed over the + * default action and the YOLO-owned rules only (by Name) — a hand-added rule + * is invisible to the diff and survives the write. On drift the whole rule set + * is rewritten as (preserved human rules + desired YOLO rules), which is the + * only update shape WAFv2 offers. + * + * @return array + */ + public function synchroniseConfiguration(bool $apply = true): array + { + $summary = WafV2::webAcl($this->name()); + $live = Aws::wafV2()->getWebACL([ + 'Name' => $this->name(), + 'Scope' => WafV2::SCOPE, + 'Id' => $summary['Id'], + ]); + + $liveRules = $live['WebACL']['Rules'] ?? []; + $changes = []; + + $liveDefault = array_key_first($live['WebACL']['DefaultAction'] ?? []); + + if ($liveDefault !== 'Allow') { + $changes[] = Change::make('default-action', $liveDefault, 'Allow'); + } + + // Loose `!=` on purpose: both sides are name-keyed maps of scalar + // signatures, so this compares key/value pairs regardless of order (a + // strict `!==` would false-flag drift on mere ordering differences). + if ($this->ownedSignatures($liveRules) != $this->desiredSignatures()) { + $changes[] = Change::make('rules', 'drift', 'reconciled (allow/block, managed groups, rate limit)'); + } + + if ($changes === [] || ! $apply) { + return $changes; + } + + Aws::wafV2()->updateWebACL([ + 'Name' => $this->name(), + 'Scope' => WafV2::SCOPE, + 'Id' => $summary['Id'], + 'LockToken' => $summary['LockToken'], + 'DefaultAction' => $this->defaultAction(), + 'Rules' => [...$this->preservedRules($liveRules), ...$this->desiredRules()], + 'VisibilityConfig' => $this->visibilityConfig($this->name()), + ]); + + return $changes; + } + + /** + * @return array> + */ + protected function defaultAction(): array + { + return ['Allow' => []]; + } + + /** + * The complete desired rule set, in priority order: the operator allow list, + * the operator block list, the AWS managed groups, then the rate limit. + * + * @return array> + */ + public function desiredRules(): array + { + $allowArn = (new AllowIpSet())->arn(); + $blockArn = (new BlockIpSet())->arn(); + + return [ + $this->ipSetRule(self::ALLOW_RULE, 0, $allowArn, action: 'Allow'), + $this->ipSetRule(self::BLOCK_RULE, 1, $blockArn, action: 'Block'), + ...$this->managedGroupRules(), + $this->rateLimitRule(), + ]; + } + + /** + * AWS managed rule groups, referenced unversioned so they track the latest + * signatures. The low-false-positive groups override to None (the group's own + * Block actions apply); CRS and SQLi override to Count so they observe without + * blocking until an operator promotes them. + * + * @return array> + */ + protected function managedGroupRules(): array + { + $groups = [ + ['name' => 'AWSManagedRulesAmazonIpReputationList', 'priority' => 10, 'override' => 'None'], + ['name' => 'AWSManagedRulesKnownBadInputsRuleSet', 'priority' => 11, 'override' => 'None'], + ['name' => 'AWSManagedRulesCommonRuleSet', 'priority' => 12, 'override' => 'Count'], + ['name' => 'AWSManagedRulesSQLiRuleSet', 'priority' => 13, 'override' => 'Count'], + ]; + + return array_map(fn (array $group): array => [ + 'Name' => 'AWS-' . $group['name'], + 'Priority' => $group['priority'], + 'OverrideAction' => [$group['override'] => []], + 'Statement' => [ + 'ManagedRuleGroupStatement' => [ + 'VendorName' => 'AWS', + 'Name' => $group['name'], + ], + ], + 'VisibilityConfig' => $this->visibilityConfig('AWS-' . $group['name']), + ], $groups); + } + + /** + * @return array + */ + protected function ipSetRule(string $name, int $priority, string $arn, string $action): array + { + return [ + 'Name' => $name, + 'Priority' => $priority, + 'Action' => [$action => []], + 'Statement' => [ + 'IPSetReferenceStatement' => ['ARN' => $arn], + ], + 'VisibilityConfig' => $this->visibilityConfig($name), + ]; + } + + /** + * @return array + */ + protected function rateLimitRule(): array + { + return [ + 'Name' => self::RATE_RULE, + 'Priority' => 20, + 'Action' => ['Block' => []], + 'Statement' => [ + 'RateBasedStatement' => [ + 'Limit' => self::RATE_LIMIT, + 'AggregateKeyType' => 'IP', + ], + ], + 'VisibilityConfig' => $this->visibilityConfig(self::RATE_RULE), + ]; + } + + /** + * Live rules YOLO doesn't own (matched by Name) — preserved verbatim through + * an update so an operator's hand-rolled rules are never clobbered. + * + * @param array> $liveRules + * @return array> + */ + protected function preservedRules(array $liveRules): array + { + $owned = $this->ownedRuleNames(); + + return array_values(array_filter( + $liveRules, + fn (array $rule): bool => ! in_array($rule['Name'], $owned, true), + )); + } + + /** + * The Names of every rule YOLO manages. + * + * @return array + */ + protected function ownedRuleNames(): array + { + return array_column($this->desiredRules(), 'Name'); + } + + /** + * A stable, echo-back-proof projection of the desired YOLO rules, keyed by + * Name — used to detect drift without tripping over fields AWS adds on read. + * + * @return array> + */ + protected function desiredSignatures(): array + { + return $this->signatures($this->desiredRules()); + } + + /** + * The same projection over the live rules YOLO owns (others are ignored). + * + * @param array> $liveRules + * @return array> + */ + protected function ownedSignatures(array $liveRules): array + { + $owned = $this->ownedRuleNames(); + + return $this->signatures(array_filter( + $liveRules, + fn (array $rule): bool => in_array($rule['Name'], $owned, true), + )); + } + + /** + * @param array> $rules + * @return array> + */ + protected function signatures(array $rules): array + { + $signatures = []; + + foreach ($rules as $rule) { + $signatures[$rule['Name']] = [ + 'priority' => $rule['Priority'], + 'statement' => $this->statementSignature($rule['Statement']), + 'action' => $this->actionSignature($rule), + ]; + } + + return $signatures; + } + + /** + * @param array $statement + */ + protected function statementSignature(array $statement): string + { + return match (true) { + isset($statement['ManagedRuleGroupStatement']) => 'managed:' + . $statement['ManagedRuleGroupStatement']['VendorName'] . ':' + . $statement['ManagedRuleGroupStatement']['Name'], + isset($statement['IPSetReferenceStatement']) => 'ipset:' + . $statement['IPSetReferenceStatement']['ARN'], + isset($statement['RateBasedStatement']) => 'rate:' + . $statement['RateBasedStatement']['Limit'] . ':' + . $statement['RateBasedStatement']['AggregateKeyType'], + default => json_encode($statement), + }; + } + + /** + * @param array $rule + */ + protected function actionSignature(array $rule): string + { + if (isset($rule['OverrideAction'])) { + return 'override:' . array_key_first($rule['OverrideAction']); + } + + if (isset($rule['Action'])) { + return 'action:' . array_key_first($rule['Action']); + } + + return 'none'; + } + + /** + * @return array + */ + protected function visibilityConfig(string $metricName): array + { + return [ + 'SampledRequestsEnabled' => true, + 'CloudWatchMetricsEnabled' => true, + 'MetricName' => $metricName, + ]; + } +} diff --git a/src/Steps/Sync/Environment/SyncWafAllowIpSetStep.php b/src/Steps/Sync/Environment/SyncWafAllowIpSetStep.php new file mode 100644 index 00000000..4bf4a045 --- /dev/null +++ b/src/Steps/Sync/Environment/SyncWafAllowIpSetStep.php @@ -0,0 +1,18 @@ +syncResource(new AllowIpSet(), $options); + } +} diff --git a/src/Steps/Sync/Environment/SyncWafAssociationStep.php b/src/Steps/Sync/Environment/SyncWafAssociationStep.php new file mode 100644 index 00000000..730ffda5 --- /dev/null +++ b/src/Steps/Sync/Environment/SyncWafAssociationStep.php @@ -0,0 +1,52 @@ +arn(); + $webAclArn = (new WebAcl())->arn(); + + $current = Aws::wafV2()->getWebACLForResource([ + 'ResourceArn' => $loadBalancerArn, + ])['WebACL']['ARN'] ?? null; + + if ($current === $webAclArn) { + return StepResult::SYNCED; + } + + $this->recordChange(Change::make('web-acl-association', $current, $webAclArn)); + + if ((bool) Arr::get($options, 'dry-run')) { + return StepResult::WOULD_SYNC; + } + + Aws::wafV2()->associateWebACL([ + 'WebACLArn' => $webAclArn, + 'ResourceArn' => $loadBalancerArn, + ]); + + return StepResult::SYNCED; + } +} diff --git a/src/Steps/Sync/Environment/SyncWafBlockIpSetStep.php b/src/Steps/Sync/Environment/SyncWafBlockIpSetStep.php new file mode 100644 index 00000000..b45e4fd6 --- /dev/null +++ b/src/Steps/Sync/Environment/SyncWafBlockIpSetStep.php @@ -0,0 +1,18 @@ +syncResource(new BlockIpSet(), $options); + } +} diff --git a/src/Steps/Sync/Environment/SyncWafWebAclStep.php b/src/Steps/Sync/Environment/SyncWafWebAclStep.php new file mode 100644 index 00000000..e7942719 --- /dev/null +++ b/src/Steps/Sync/Environment/SyncWafWebAclStep.php @@ -0,0 +1,18 @@ +syncResource(new WebAcl(), $options); + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 1a9f095b..5f725805 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,6 +6,7 @@ use Aws\Ecs\EcsClient; use Aws\Iam\IamClient; use Aws\CommandInterface; +use Aws\WAFV2\WAFV2Client; use Codinglabs\Yolo\Helpers; use GuzzleHttp\Promise\Create; use Symfony\Component\Yaml\Yaml; @@ -513,3 +514,45 @@ public function __invoke(CommandInterface $cmd, $request) 'handler' => $mock, ])); } + +/** + * Bind a mock WAFv2 client with command-routed responses, capturing every call. + * A command's value may be a single Result (repeated) or an array of Results used + * as a queue (the last entry repeats once exhausted). Mirrors bindMockEc2Client. + * + * @param array> $byCommand + * @param array}> $captured + */ +function bindRoutedWafV2Client(array $byCommand, array &$captured): void +{ + $mock = new class($byCommand, $captured) extends MockHandler + { + /** @var array */ + private array $cursors = []; + + public function __construct(protected array $byCommand, protected array &$captured) {} + + public function __invoke(CommandInterface $cmd, $request) + { + $name = $cmd->getName(); + $this->captured[] = ['name' => $name, 'args' => $cmd->toArray()]; + + $entry = $this->byCommand[$name] ?? new Result(); + + if (is_array($entry)) { + $index = min($this->cursors[$name] ?? 0, count($entry) - 1); + $this->cursors[$name] = $index + 1; + $entry = $entry[$index]; + } + + return Create::promiseFor($entry); + } + }; + + Helpers::app()->instance('wafV2', new WAFV2Client([ + 'region' => 'ap-southeast-2', + 'version' => 'latest', + 'credentials' => false, + 'handler' => $mock, + ])); +} diff --git a/tests/Unit/Aws/ResourceGroupsTaggingApiTest.php b/tests/Unit/Aws/ResourceGroupsTaggingApiTest.php index 2d852924..13c33f77 100644 --- a/tests/Unit/Aws/ResourceGroupsTaggingApiTest.php +++ b/tests/Unit/Aws/ResourceGroupsTaggingApiTest.php @@ -79,11 +79,11 @@ function rgtPage(array $mappings, string $token = ''): Result rgtMapping('arn:aws:iam::111:role/yolo-production-codinglabs-task-role', [ 'yolo:app' => 'codinglabs', 'yolo:scope' => 'app', 'Name' => 'yolo-production-codinglabs-task-role', ]), - // A YOLO-owned global resource of a service YOLO no longer provisions - // (a us-east-1 WAFv2 web ACL) — the global analogue of the DynamoDB - // sessions orphan: owned by a live app, but no Resources/ class, so a - // sync would never recreate it → unexpected, service no longer provisioned. - rgtMapping('arn:aws:wafv2:us-east-1:111:global/webacl/yolo-production-codinglabs/abc', [ + // A YOLO-owned global resource of a service YOLO doesn't provision + // (a Global Accelerator) — the global analogue of the DynamoDB sessions + // orphan: owned by a live app, but no Resources/ class, so a sync would + // never recreate it → unexpected, service no longer provisioned. + rgtMapping('arn:aws:globalaccelerator::111:accelerator/yolo-production-codinglabs', [ 'yolo:app' => 'codinglabs', 'yolo:scope' => 'app', 'Name' => 'yolo-production-codinglabs', ]), ]), @@ -97,6 +97,6 @@ function rgtPage(array $mappings, string $token = ''): Result $byArn = collect($report['resources'])->keyBy('arn'); expect($byArn['arn:aws:iam::111:role/yolo-production-codinglabs-task-role']['status'])->toBe('ok') - ->and($byArn['arn:aws:wafv2:us-east-1:111:global/webacl/yolo-production-codinglabs/abc']['status'])->toBe('unexpected') - ->and($byArn['arn:aws:wafv2:us-east-1:111:global/webacl/yolo-production-codinglabs/abc']['reason'])->toBe(Audit::REASON_UNMANAGED_SERVICE); + ->and($byArn['arn:aws:globalaccelerator::111:accelerator/yolo-production-codinglabs']['status'])->toBe('unexpected') + ->and($byArn['arn:aws:globalaccelerator::111:accelerator/yolo-production-codinglabs']['reason'])->toBe(Audit::REASON_UNMANAGED_SERVICE); }); diff --git a/tests/Unit/Concerns/SkipReasonTest.php b/tests/Unit/Concerns/SkipReasonTest.php index d31a0380..d88d8995 100644 --- a/tests/Unit/Concerns/SkipReasonTest.php +++ b/tests/Unit/Concerns/SkipReasonTest.php @@ -5,6 +5,7 @@ use Codinglabs\Yolo\Enums\StepResult; use Codinglabs\Yolo\Commands\SyncCommand; use Codinglabs\Yolo\Contracts\ExecutesIvsStep; +use Codinglabs\Yolo\Contracts\ExecutesWafStep; use Codinglabs\Yolo\Contracts\ExecutesWebStep; use Codinglabs\Yolo\Contracts\ExecutesSoloStep; use Codinglabs\Yolo\Contracts\ExecutesMultitenancyStep; @@ -95,6 +96,27 @@ function skipReasonFor(object $step): ?string }))->toBeNull(); }); +it('skips WAF steps when waf is not enabled', function (): void { + writeManifest(['account-id' => '111111111111', 'region' => 'ap-southeast-2']); + + expect(skipReasonFor(new class() implements ExecutesWafStep + { + use FakeStepInvoke; + })) + ->toBe('waf not enabled in manifest'); +}); + +it('runs WAF steps when waf is enabled', function (): void { + writeManifest([ + 'account-id' => '111111111111', 'region' => 'ap-southeast-2', 'waf' => true, + ]); + + expect(skipReasonFor(new class() implements ExecutesWafStep + { + use FakeStepInvoke; + }))->toBeNull(); +}); + it('runs a plain step with no structural gate', function (): void { writeManifest(['account-id' => '111111111111', 'region' => 'ap-southeast-2']); diff --git a/tests/Unit/ManifestTest.php b/tests/Unit/ManifestTest.php index 6791fdfa..24a8d757 100644 --- a/tests/Unit/ManifestTest.php +++ b/tests/Unit/ManifestTest.php @@ -208,6 +208,32 @@ }); }); +describe('wafEnabled', function (): void { + it('is false when waf is absent', function (): void { + writeManifest([]); + + expect(Manifest::wafEnabled())->toBeFalse(); + }); + + it('is true when waf is enabled', function (): void { + writeManifest(['waf' => true]); + + expect(Manifest::wafEnabled())->toBeTrue(); + }); + + it('is false when waf is explicitly false', function (): void { + writeManifest(['waf' => false]); + + expect(Manifest::wafEnabled())->toBeFalse(); + }); + + it('is an accepted environment key', function (): void { + writeManifest(['waf' => true]); + + expect(Manifest::unknownKeys())->not->toContain('environments.testing.waf'); + }); +}); + describe('apex', function (): void { it('returns the apex domain', function (): void { writeManifest(['domain' => 'example.com']); diff --git a/tests/Unit/Resources/WafV2/IpSetTest.php b/tests/Unit/Resources/WafV2/IpSetTest.php new file mode 100644 index 00000000..b5e83d30 --- /dev/null +++ b/tests/Unit/Resources/WafV2/IpSetTest.php @@ -0,0 +1,88 @@ + '111111111111', 'region' => 'ap-southeast-2']); +}); + +function allowIpSetListResult(): Result +{ + return new Result(['IPSets' => [ + ['Name' => 'yolo-testing-waf-allow', 'Id' => 'allow-id', 'LockToken' => 'lt', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/ipset/yolo-testing-waf-allow/allow-id'], + ]]); +} + +function allowIpSetTagsResult(): Result +{ + return new Result(['TagInfoForResource' => ['TagList' => [ + ['Key' => 'Name', 'Value' => 'yolo-testing-waf-allow'], + ['Key' => 'yolo:scope', 'Value' => 'env'], + ['Key' => 'yolo:environment', 'Value' => 'testing'], + ]]]); +} + +it('names the allow and block sets for the environment scope', function (): void { + expect((new AllowIpSet())->name())->toBe('yolo-testing-waf-allow') + ->and((new BlockIpSet())->name())->toBe('yolo-testing-waf-block'); +}); + +it('creates an IP set seeded empty', function (): void { + $captured = []; + bindRoutedWafV2Client(['CreateIPSet' => new Result([])], $captured); + + (new AllowIpSet())->create(); + + $create = collect($captured)->firstWhere('name', 'CreateIPSet'); + + expect($create['args']['Name'])->toBe('yolo-testing-waf-allow') + ->and($create['args']['Scope'])->toBe('REGIONAL') + ->and($create['args']['IPAddressVersion'])->toBe('IPV4') + ->and($create['args']['Addresses'])->toBe([]) + ->and($create['args']['Tags'])->toContain(['Key' => 'yolo:scope', 'Value' => 'env']); +}); + +it('creates the set when it is absent', function (): void { + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => new Result(['IPSets' => []]), + 'CreateIPSet' => new Result([]), + ], $captured); + + expect((new SyncWafAllowIpSetStep())([]))->toBe(StepResult::CREATED); + expect(array_column($captured, 'name'))->toContain('CreateIPSet'); +}); + +it('creates the block set when it is absent', function (): void { + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => new Result(['IPSets' => []]), + 'CreateIPSet' => new Result([]), + ], $captured); + + expect((new SyncWafBlockIpSetStep())([]))->toBe(StepResult::CREATED); + + $create = collect($captured)->firstWhere('name', 'CreateIPSet'); + expect($create['args']['Name'])->toBe('yolo-testing-waf-block'); +}); + +it('never reconciles IP set contents on an existing set — only tags', function (): void { + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => allowIpSetListResult(), + 'ListTagsForResource' => allowIpSetTagsResult(), + ], $captured); + + expect((new SyncWafAllowIpSetStep())([]))->toBe(StepResult::SYNCED); + + // The high-churn contents are create-only: a sync over an existing set must + // never rewrite its addresses, so an operator's mid-incident edits survive. + expect(array_column($captured, 'name')) + ->not->toContain('UpdateIPSet') + ->not->toContain('CreateIPSet'); +}); diff --git a/tests/Unit/Resources/WafV2/WebAclTest.php b/tests/Unit/Resources/WafV2/WebAclTest.php new file mode 100644 index 00000000..29b0d287 --- /dev/null +++ b/tests/Unit/Resources/WafV2/WebAclTest.php @@ -0,0 +1,192 @@ + '111111111111', 'region' => 'ap-southeast-2']); +}); + +function wafIpSetsResult(): Result +{ + return new Result(['IPSets' => [ + ['Name' => 'yolo-testing-waf-allow', 'Id' => 'allow-id', 'LockToken' => 'lt-allow', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/ipset/yolo-testing-waf-allow/allow-id'], + ['Name' => 'yolo-testing-waf-block', 'Id' => 'block-id', 'LockToken' => 'lt-block', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/ipset/yolo-testing-waf-block/block-id'], + ]]); +} + +function wafWebAclsResult(): Result +{ + return new Result(['WebACLs' => [ + ['Name' => 'yolo-testing-waf', 'Id' => 'acl-id', 'LockToken' => 'lt-acl', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/webacl/yolo-testing-waf/acl-id'], + ]]); +} + +function wafWebAclTagsResult(): Result +{ + return new Result(['TagInfoForResource' => ['TagList' => [ + ['Key' => 'Name', 'Value' => 'yolo-testing-waf'], + ['Key' => 'yolo:scope', 'Value' => 'env'], + ['Key' => 'yolo:environment', 'Value' => 'testing'], + ]]]); +} + +/** A live GetWebACL response wrapping the given rules + default action. */ +function liveWebAclResult(array $rules, array $defaultAction = ['Allow' => []]): Result +{ + return new Result([ + 'WebACL' => ['Rules' => $rules, 'DefaultAction' => $defaultAction], + 'LockToken' => 'lt-acl', + ]); +} + +/** The resource's own desired rules, resolved against the mocked IP sets. */ +function desiredWafRules(): array +{ + $captured = []; + bindRoutedWafV2Client(['ListIPSets' => wafIpSetsResult()], $captured); + + return (new WebAcl())->desiredRules(); +} + +it('is named for the environment scope', function (): void { + expect((new WebAcl())->name())->toBe('yolo-testing-waf'); +}); + +it('creates the web ACL with an allow default action and the full rule skeleton', function (): void { + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'CreateWebACL' => new Result(['Summary' => ['ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/webacl/yolo-testing-waf/acl-id']]), + ], $captured); + + (new WebAcl())->create(); + + $create = collect($captured)->firstWhere('name', 'CreateWebACL'); + + expect($create['args']['Name'])->toBe('yolo-testing-waf') + ->and($create['args']['Scope'])->toBe('REGIONAL') + ->and($create['args']['DefaultAction'])->toBe(['Allow' => []]); + + $ruleNames = array_column($create['args']['Rules'], 'Name'); + + expect($ruleNames)->toBe([ + 'yolo-allow-ips', + 'yolo-block-ips', + 'AWS-AWSManagedRulesAmazonIpReputationList', + 'AWS-AWSManagedRulesKnownBadInputsRuleSet', + 'AWS-AWSManagedRulesCommonRuleSet', + 'AWS-AWSManagedRulesSQLiRuleSet', + 'yolo-rate-limit', + ]); + + // The noisy managed groups ship in Count; the low-false-positive ones block. + $byName = collect($create['args']['Rules'])->keyBy('Name'); + expect($byName['AWS-AWSManagedRulesCommonRuleSet']['OverrideAction'])->toBe(['Count' => []]) + ->and($byName['AWS-AWSManagedRulesSQLiRuleSet']['OverrideAction'])->toBe(['Count' => []]) + ->and($byName['AWS-AWSManagedRulesAmazonIpReputationList']['OverrideAction'])->toBe(['None' => []]) + ->and($byName['AWS-AWSManagedRulesKnownBadInputsRuleSet']['OverrideAction'])->toBe(['None' => []]); + + // Managed groups are referenced unversioned so they track the latest signatures. + expect($byName['AWS-AWSManagedRulesCommonRuleSet']['Statement']['ManagedRuleGroupStatement']) + ->not->toHaveKey('Version'); + + expect($create['args']['Tags'])->toContain(['Key' => 'yolo:scope', 'Value' => 'env']); +}); + +it('reports exists from the live web ACL list', function (): void { + $captured = []; + bindRoutedWafV2Client(['ListWebACLs' => wafWebAclsResult()], $captured); + expect((new WebAcl())->exists())->toBeTrue(); + + bindRoutedWafV2Client(['ListWebACLs' => new Result(['WebACLs' => []])], $captured); + expect((new WebAcl())->exists())->toBeFalse(); +}); + +it('reports no change when the live policy matches', function (): void { + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACL' => liveWebAclResult(desiredWafRules()), + ], $captured); + + expect((new WebAcl())->synchroniseConfiguration())->toBe([]); + expect(array_column($captured, 'name'))->not->toContain('UpdateWebACL'); +}); + +it('detects a default-action drift and rewrites the ACL', function (): void { + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACL' => liveWebAclResult(desiredWafRules(), defaultAction: ['Block' => []]), + 'UpdateWebACL' => new Result(['NextLockToken' => 'lt-next']), + ], $captured); + + $changes = (new WebAcl())->synchroniseConfiguration(); + + expect(collect($changes)->pluck('attribute'))->toContain('default-action'); + expect(array_column($captured, 'name'))->toContain('UpdateWebACL'); +}); + +it('detects a missing managed rule and rewrites the ACL', function (): void { + $captured = []; + $partial = collect(desiredWafRules()) + ->reject(fn (array $rule): bool => $rule['Name'] === 'AWS-AWSManagedRulesSQLiRuleSet') + ->values() + ->all(); + + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACL' => liveWebAclResult($partial), + 'UpdateWebACL' => new Result(['NextLockToken' => 'lt-next']), + ], $captured); + + $changes = (new WebAcl())->synchroniseConfiguration(); + + expect(collect($changes)->pluck('attribute'))->toContain('rules'); + expect(array_column($captured, 'name'))->toContain('UpdateWebACL'); +}); + +it('computes the diff without writing under apply:false', function (): void { + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACL' => liveWebAclResult(desiredWafRules(), defaultAction: ['Block' => []]), + ], $captured); + + expect((new WebAcl())->synchroniseConfiguration(apply: false))->not->toBeEmpty(); + expect(array_column($captured, 'name'))->not->toContain('UpdateWebACL'); +}); + +it('preserves a hand-added rule through a reconciling update', function (): void { + $humanRule = [ + 'Name' => 'operator-geo-block', + 'Priority' => 100, + 'Action' => ['Block' => []], + 'Statement' => ['GeoMatchStatement' => ['CountryCodes' => ['CN']]], + 'VisibilityConfig' => ['SampledRequestsEnabled' => true, 'CloudWatchMetricsEnabled' => true, 'MetricName' => 'operator-geo-block'], + ]; + + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'ListWebACLs' => wafWebAclsResult(), + // Drift (Block default) forces the update; the human rule rides alongside. + 'GetWebACL' => liveWebAclResult([$humanRule, ...desiredWafRules()], defaultAction: ['Block' => []]), + 'UpdateWebACL' => new Result(['NextLockToken' => 'lt-next']), + ], $captured); + + (new WebAcl())->synchroniseConfiguration(); + + $update = collect($captured)->firstWhere('name', 'UpdateWebACL'); + $writtenNames = array_column($update['args']['Rules'], 'Name'); + + expect($writtenNames)->toContain('operator-geo-block') + ->and($writtenNames)->toContain('yolo-allow-ips') + ->and($writtenNames)->toContain('AWS-AWSManagedRulesSQLiRuleSet') + ->and($update['args']['LockToken'])->toBe('lt-acl'); +}); diff --git a/tests/Unit/Steps/Waf/WafStepsTest.php b/tests/Unit/Steps/Waf/WafStepsTest.php new file mode 100644 index 00000000..683fb0ab --- /dev/null +++ b/tests/Unit/Steps/Waf/WafStepsTest.php @@ -0,0 +1,87 @@ + '111111111111', 'region' => 'ap-southeast-2', 'waf' => true, + ]); +}); + +const WAF_WEBACL_ARN = 'arn:aws:wafv2:ap-southeast-2:111:regional/webacl/yolo-testing-waf/acl-id'; + +function wafLoadBalancerResult(): Result +{ + return new Result(['LoadBalancers' => [[ + 'LoadBalancerName' => 'yolo-testing', + 'LoadBalancerArn' => 'arn:aws:elasticloadbalancing:ap-southeast-2:111:loadbalancer/app/yolo-testing/abc', + ]]]); +} + +it('honours the reconciler contract for the web ACL', function (): void { + // Resolve the desired rules once (reuses the WafV2 fixtures from WebAclTest). + $desired = desiredWafRules(); + + assertSyncStepReconciles( + makeStep: fn (): SyncWafWebAclStep => new SyncWafWebAclStep(), + bindInSync: function (array &$captured) use ($desired): void { + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACL' => liveWebAclResult($desired), + 'ListTagsForResource' => wafWebAclTagsResult(), + ], $captured); + }, + bindDrifted: function (array &$captured) use ($desired): void { + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACL' => liveWebAclResult($desired, defaultAction: ['Block' => []]), + 'ListTagsForResource' => wafWebAclTagsResult(), + 'UpdateWebACL' => new Result(['NextLockToken' => 'lt-next']), + ], $captured); + }, + writeCommand: 'UpdateWebACL', + ); +}); + +it('honours the reconciler contract for the ALB association', function (): void { + assertSyncStepReconciles( + makeStep: fn (): SyncWafAssociationStep => new SyncWafAssociationStep(), + bindInSync: function (array &$captured): void { + bindRoutedElbV2Client(['DescribeLoadBalancers' => wafLoadBalancerResult()], $captured); + bindRoutedWafV2Client([ + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACLForResource' => new Result(['WebACL' => ['ARN' => WAF_WEBACL_ARN]]), + ], $captured); + }, + bindDrifted: function (array &$captured): void { + bindRoutedElbV2Client(['DescribeLoadBalancers' => wafLoadBalancerResult()], $captured); + bindRoutedWafV2Client([ + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACLForResource' => new Result([]), + 'AssociateWebACL' => new Result([]), + ], $captured); + }, + writeCommand: 'AssociateWebACL', + ); +}); + +it('points the load balancer at the web ACL when unassociated', function (): void { + $captured = []; + bindRoutedElbV2Client(['DescribeLoadBalancers' => wafLoadBalancerResult()], $captured); + bindRoutedWafV2Client([ + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACLForResource' => new Result([]), + 'AssociateWebACL' => new Result([]), + ], $captured); + + (new SyncWafAssociationStep())([]); + + $associate = collect($captured)->firstWhere('name', 'AssociateWebACL'); + + expect($associate['args']['WebACLArn'])->toBe(WAF_WEBACL_ARN) + ->and($associate['args']['ResourceArn'])->toBe('arn:aws:elasticloadbalancing:ap-southeast-2:111:loadbalancer/app/yolo-testing/abc'); +}); From 55d95762268b676db7ec9a9c443b5449b37fb6d1 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 12:18:42 +1000 Subject: [PATCH 02/11] test(waf): move shared WAF fixtures to Pest.php for parallel isolation Under `pest --parallel` each test file runs in its own worker, so the WAF builder fns that WafStepsTest borrowed from WebAclTest were undefined there. Relocate them to tests/Pest.php (loaded by every worker). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Pest.php | 55 +++++++++++++++++++++++ tests/Unit/Resources/WafV2/WebAclTest.php | 42 ----------------- 2 files changed, 55 insertions(+), 42 deletions(-) diff --git a/tests/Pest.php b/tests/Pest.php index 5f725805..f75fb1a1 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -556,3 +556,58 @@ public function __invoke(CommandInterface $cmd, $request) 'handler' => $mock, ])); } + +/** + * Shared WAF fixtures — defined here (not in a single test file) so every Pest + * worker has them under `--parallel`, where each test file runs in isolation. + */ +function wafIpSetsResult(): Result +{ + return new Result(['IPSets' => [ + ['Name' => 'yolo-testing-waf-allow', 'Id' => 'allow-id', 'LockToken' => 'lt-allow', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/ipset/yolo-testing-waf-allow/allow-id'], + ['Name' => 'yolo-testing-waf-block', 'Id' => 'block-id', 'LockToken' => 'lt-block', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/ipset/yolo-testing-waf-block/block-id'], + ]]); +} + +function wafWebAclsResult(): Result +{ + return new Result(['WebACLs' => [ + ['Name' => 'yolo-testing-waf', 'Id' => 'acl-id', 'LockToken' => 'lt-acl', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/webacl/yolo-testing-waf/acl-id'], + ]]); +} + +function wafWebAclTagsResult(): Result +{ + return new Result(['TagInfoForResource' => ['TagList' => [ + ['Key' => 'Name', 'Value' => 'yolo-testing-waf'], + ['Key' => 'yolo:scope', 'Value' => 'env'], + ['Key' => 'yolo:environment', 'Value' => 'testing'], + ]]]); +} + +/** + * A live GetWebACL response wrapping the given rules + default action. + * + * @param array> $rules + * @param array $defaultAction + */ +function liveWebAclResult(array $rules, array $defaultAction = ['Allow' => []]): Result +{ + return new Result([ + 'WebACL' => ['Rules' => $rules, 'DefaultAction' => $defaultAction], + 'LockToken' => 'lt-acl', + ]); +} + +/** + * The WebAcl resource's own desired rules, resolved against the mocked IP sets. + * + * @return array> + */ +function desiredWafRules(): array +{ + $captured = []; + bindRoutedWafV2Client(['ListIPSets' => wafIpSetsResult()], $captured); + + return (new Codinglabs\Yolo\Resources\WafV2\WebAcl())->desiredRules(); +} diff --git a/tests/Unit/Resources/WafV2/WebAclTest.php b/tests/Unit/Resources/WafV2/WebAclTest.php index 29b0d287..2222f47d 100644 --- a/tests/Unit/Resources/WafV2/WebAclTest.php +++ b/tests/Unit/Resources/WafV2/WebAclTest.php @@ -7,48 +7,6 @@ writeManifest(['account-id' => '111111111111', 'region' => 'ap-southeast-2']); }); -function wafIpSetsResult(): Result -{ - return new Result(['IPSets' => [ - ['Name' => 'yolo-testing-waf-allow', 'Id' => 'allow-id', 'LockToken' => 'lt-allow', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/ipset/yolo-testing-waf-allow/allow-id'], - ['Name' => 'yolo-testing-waf-block', 'Id' => 'block-id', 'LockToken' => 'lt-block', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/ipset/yolo-testing-waf-block/block-id'], - ]]); -} - -function wafWebAclsResult(): Result -{ - return new Result(['WebACLs' => [ - ['Name' => 'yolo-testing-waf', 'Id' => 'acl-id', 'LockToken' => 'lt-acl', 'ARN' => 'arn:aws:wafv2:ap-southeast-2:111:regional/webacl/yolo-testing-waf/acl-id'], - ]]); -} - -function wafWebAclTagsResult(): Result -{ - return new Result(['TagInfoForResource' => ['TagList' => [ - ['Key' => 'Name', 'Value' => 'yolo-testing-waf'], - ['Key' => 'yolo:scope', 'Value' => 'env'], - ['Key' => 'yolo:environment', 'Value' => 'testing'], - ]]]); -} - -/** A live GetWebACL response wrapping the given rules + default action. */ -function liveWebAclResult(array $rules, array $defaultAction = ['Allow' => []]): Result -{ - return new Result([ - 'WebACL' => ['Rules' => $rules, 'DefaultAction' => $defaultAction], - 'LockToken' => 'lt-acl', - ]); -} - -/** The resource's own desired rules, resolved against the mocked IP sets. */ -function desiredWafRules(): array -{ - $captured = []; - bindRoutedWafV2Client(['ListIPSets' => wafIpSetsResult()], $captured); - - return (new WebAcl())->desiredRules(); -} - it('is named for the environment scope', function (): void { expect((new WebAcl())->name())->toBe('yolo-testing-waf'); }); From 81bc1c730ddbcf65e482f3e724e4c593ee7d7b86 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 12:19:31 +1000 Subject: [PATCH 03/11] =?UTF-8?q?style(waf):=20pint=20=E2=80=94=20top-impo?= =?UTF-8?q?rt=20WebAcl=20in=20Pest.php?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Pest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Pest.php b/tests/Pest.php index f75fb1a1..c434997a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -14,6 +14,7 @@ use Aws\CloudWatch\CloudWatchClient; use Codinglabs\Yolo\Enums\StepResult; use Aws\ElastiCache\ElastiCacheClient; +use Codinglabs\Yolo\Resources\WafV2\WebAcl; use Aws\ApplicationAutoScaling\ApplicationAutoScalingClient; use Aws\ElasticLoadBalancingV2\ElasticLoadBalancingV2Client; use Aws\ResourceGroupsTaggingAPI\ResourceGroupsTaggingAPIClient; @@ -609,5 +610,5 @@ function desiredWafRules(): array $captured = []; bindRoutedWafV2Client(['ListIPSets' => wafIpSetsResult()], $captured); - return (new Codinglabs\Yolo\Resources\WafV2\WebAcl())->desiredRules(); + return (new WebAcl())->desiredRules(); } From 991923f6e0658a6973842f317d8bb586f27fbade Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 15:39:15 +1000 Subject: [PATCH 04/11] refactor(waf): make the WAF a compulsory env resource, drop the `waf:` toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WAF isn't an optional feature like `ivs` — it's compulsory env infrastructure like the ALB or VPC. There's no app that benefits from having no WAF, and an app with genuinely incompatible needs sits on its own load balancer/account anyway. Making it a per-app manifest toggle invented a competing-manifests conflict (whose `waf:` wins on a shared ALB?) to solve a problem that doesn't exist. So: no `waf:` key, no `wafEnabled()`, no `ExecutesWafStep` gate. The four WAF steps now run as standard `sync:environment` work gated on `ExecutesWebStep` (skip only when the env is headless — no ALB to protect), exactly like the ALB steps beside them. Provisioned automatically for every environment with a load balancer; reconciled on every sync; conflict-free. Docs move from a manifest-key reference to a "Web application firewall" section in the provisioning guide (it's a resource now, not config). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/provisioning.md | 26 +++++++++++++- docs/reference/commands.md | 2 +- docs/reference/manifest.md | 35 ++----------------- .../ChecksIfCommandsShouldBeRunning.php | 5 --- src/Contracts/ExecutesWafStep.php | 7 ---- src/Manifest.php | 11 ------ .../Environment/SyncWafAllowIpSetStep.php | 4 +-- .../Environment/SyncWafAssociationStep.php | 4 +-- .../Environment/SyncWafBlockIpSetStep.php | 4 +-- .../Sync/Environment/SyncWafWebAclStep.php | 4 +-- tests/Unit/Concerns/SkipReasonTest.php | 22 ------------ tests/Unit/ManifestTest.php | 26 -------------- tests/Unit/Steps/Waf/WafStepsTest.php | 4 +-- 13 files changed, 38 insertions(+), 116 deletions(-) delete mode 100644 src/Contracts/ExecutesWafStep.php diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index 56aade8e..11fea2fe 100644 --- a/docs/guide/provisioning.md +++ b/docs/guide/provisioning.md @@ -13,7 +13,7 @@ YOLO groups every resource by **ownership scope** — the blast radius if it cha | Command | Scope | Blast radius | Provisions | |---|---|---|---| | `yolo sync:account ` | **Account** | the whole AWS account | GitHub OIDC provider | -| `yolo sync:environment ` | **Environment** | every app in the environment | VPC, subnets, internet gateway & routes, RDS security group, SNS alarm topic, the shared ECS execution IAM role, the ALB and its `:80`/`:443` listeners | +| `yolo sync:environment ` | **Environment** | every app in the environment | VPC, subnets, internet gateway & routes, RDS security group, SNS alarm topic, the shared ECS execution IAM role, the ALB and its `:80`/`:443` listeners, the [WAF](#web-application-firewall) fronting the ALB | | `yolo sync:app ` | **App** | one app | S3 buckets, app IAM (deployer role/policy, the per-app ECS task role + any [`task-role-policies`](/reference/manifest#task-role-policies)), ECS cluster/service/task definition, target group + listener rule, CloudFront distribution, hosted zone & ACM certificate, SQS queues, CloudWatch dashboard — plus, for web apps, the shared [Valkey cache](#cache-and-sessions) (default-on; opt out via `cache.store`). Sessions ride the same Valkey cluster by default, so they need no provisioning of their own | The bare `yolo sync` runs all three **in dependency order** — account, then environment, then app: @@ -30,6 +30,30 @@ The shared **Valkey cache** is env-scoped but bootstrapped from `sync:app` by ex Several apps can share one environment's VPC and load balancer. Because `sync:app` only attaches and never mutates, deploying app B can't break app A's networking. When you're iterating on one app, `sync:app` is faster than a full `sync` — the account and environment tiers rarely change. ::: +## Web application firewall + +Every environment with a load balancer gets a regional [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html) web ACL on its ALB — automatically, with no manifest key. It's compulsory infrastructure, like the ALB itself: one web ACL protects every app sharing the load balancer, and there's no reason for an app to opt out (an app with genuinely incompatible needs belongs on its own load balancer or account). + +YOLO owns the **policy skeleton** and reconciles it on every sync; you own the **list contents**, which sync never touches: + +| YOLO reconciles (declarative) | You manage (console, never reconciled) | +| --- | --- | +| Default action (`Allow`), the allow/block rule wiring, the AWS managed rule groups and their actions, the per-IP rate limit | The CIDRs inside the allow / block IP sets, and any rule you add by hand | + +The default skeleton, in priority order: + +| Rule | Action | Notes | +| --- | --- | --- | +| Allow IP set | Allow | Seeded **empty** — add known-good IPs (e.g. crawler ranges a managed group might false-positive). | +| Block IP set | Block | Seeded **empty** — the lever for shutting down an abusive source mid-incident. | +| Amazon IP reputation list | Block | Low false-positive; auto-evolves. | +| Known bad inputs | Block | Low false-positive; auto-evolves. | +| Core rule set (CRS) | **Count** | Ships in Count so a new AWS signature can't start blocking live traffic unannounced — promote to Block once you've watched the metrics. | +| SQL injection | **Count** | Same Count-first treatment. | +| Rate limit | Block | ~2000 requests / 5 min **per source IP**. | + +The managed groups are referenced **unversioned**, so AWS's signature and IP-reputation updates roll in automatically — the WAF improves over time without a YOLO change. The IP sets are **create-only**: an IP you add in the console survives every subsequent `sync`. A rule you add by hand (matched by name) is preserved too — YOLO only ever rewrites the rules it owns. + ## Plan, confirm, apply `sync` never surprises you. It runs as a three-step flow: diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 6b6ca42a..0f87e829 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -276,7 +276,7 @@ Arguments and options as [`sync`](#sync-options). Scope: **account**. ## `yolo sync:environment` -Sync the environment-shared (environment-tier) resources — VPC, subnets, internet gateway and routes, the load balancer security group, the ALB and its `:80` listener, the SNS alarm topic, the shared ECS execution IAM role, and — when [`waf: true`](/reference/manifest#waf) — the WAF web ACL and its allow/block IP sets fronting the ALB. +Sync the environment-shared (environment-tier) resources — VPC, subnets, internet gateway and routes, the load balancer security group, the ALB and its `:80` listener, the SNS alarm topic, the shared ECS execution IAM role, and the [WAF web ACL](/guide/provisioning#web-application-firewall) (with its allow/block IP sets) fronting the ALB. ```bash yolo sync:environment [--check] [--force] [--no-progress] [--tenant=] diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md index 46df1f6b..60af0ace 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -49,9 +49,6 @@ environments: # log-retention-days: 30 # default: 14 — CloudWatch retention # mediaconvert: arn:aws:iam::123456789012:role/MediaConvertRole # transcoding role ARN (used with IVS) - # --- Security --- - # waf: true # front the env load balancer with a managed WAF web ACL (env-scoped, off by default) - # --- Extra IAM for this app's task role (per-app; never reaches another app) --- # task-role-policies: # - arn:aws:iam::123456789012:policy/my-app-extra-access @@ -244,35 +241,9 @@ ivs: MediaConvert role ARN for video transcoding workloads (used with IVS). -### `waf` - -Fronts the environment load balancer with a YOLO-managed [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html) web ACL. Set to `true`: - -```yaml -waf: true -``` - -It's **environment scoped** — one web ACL protects every app sharing the ALB — so it's written by [`yolo sync:environment`](/reference/commands#yolo-sync-environment), not `sync:app`. Off by default; provisioning is purely additive once on. - -YOLO owns the **policy skeleton** and reconciles it on every sync; you own the **list contents**, which sync never touches: - -| YOLO reconciles (declarative) | You manage (console, never reconciled) | -| --- | --- | -| Default action (`Allow`), the allow/block rule wiring, the AWS managed rule groups and their actions, the per-IP rate limit | The CIDRs inside the allow / block IP sets, and any rule you add by hand | - -The default skeleton, in priority order: - -| Rule | Action | Notes | -| --- | --- | --- | -| Allow IP set | Allow | Seeded **empty** — add known-good IPs (e.g. crawler ranges a managed group might false-positive). | -| Block IP set | Block | Seeded **empty** — the lever for shutting down an abusive source mid-incident. | -| Amazon IP reputation list | Block | Low false-positive; auto-evolves. | -| Known bad inputs | Block | Low false-positive; auto-evolves. | -| Core rule set (CRS) | **Count** | Ships in Count so a new AWS signature can't start blocking live traffic unannounced — promote to Block once you've watched the metrics. | -| SQL injection | **Count** | Same Count-first treatment. | -| Rate limit | Block | ~2000 requests / 5 min **per source IP**. | - -The managed groups are referenced **unversioned**, so AWS's signature and IP-reputation updates roll in automatically — the WAF gets better over time without a YOLO change. The IP sets are **create-only**: an IP you add in the console survives every subsequent `sync`. A rule you add by hand (matched by name) is preserved too — YOLO only ever rewrites the rules it owns. `waf` needs a web/ALB environment; it has no effect on a headless app. +::: tip No `waf` key +The [web application firewall](/guide/provisioning#web-application-firewall) is a **compulsory** environment resource — every environment with a load balancer gets one automatically, so there's nothing to configure here. Day-to-day tuning happens in its allow/block IP sets, not the manifest. +::: ### `task-role-policies` diff --git a/src/Concerns/ChecksIfCommandsShouldBeRunning.php b/src/Concerns/ChecksIfCommandsShouldBeRunning.php index 15683d56..b44fb343 100644 --- a/src/Concerns/ChecksIfCommandsShouldBeRunning.php +++ b/src/Concerns/ChecksIfCommandsShouldBeRunning.php @@ -8,7 +8,6 @@ use Codinglabs\Yolo\Commands\Command; use Codinglabs\Yolo\Contracts\RunsOnAws; use Codinglabs\Yolo\Contracts\ExecutesIvsStep; -use Codinglabs\Yolo\Contracts\ExecutesWafStep; use Codinglabs\Yolo\Contracts\ExecutesWebStep; use Codinglabs\Yolo\Contracts\ExecutesSoloStep; use Codinglabs\Yolo\Contracts\ExecutesMultitenancyStep; @@ -41,10 +40,6 @@ public function skipReason(Command|Step $instance): ?string return 'ivs not enabled in manifest'; } - if ($instance instanceof ExecutesWafStep && ! Manifest::wafEnabled()) { - return 'waf not enabled in manifest'; - } - if (Aws::runningInAws()) { return $instance instanceof RunsOnAws ? null : 'does not run on AWS instances'; } diff --git a/src/Contracts/ExecutesWafStep.php b/src/Contracts/ExecutesWafStep.php deleted file mode 100644 index ba57292a..00000000 --- a/src/Contracts/ExecutesWafStep.php +++ /dev/null @@ -1,7 +0,0 @@ -> */ diff --git a/src/Steps/Sync/Environment/SyncWafAllowIpSetStep.php b/src/Steps/Sync/Environment/SyncWafAllowIpSetStep.php index 4bf4a045..3f2d6d36 100644 --- a/src/Steps/Sync/Environment/SyncWafAllowIpSetStep.php +++ b/src/Steps/Sync/Environment/SyncWafAllowIpSetStep.php @@ -3,11 +3,11 @@ namespace Codinglabs\Yolo\Steps\Sync\Environment; use Codinglabs\Yolo\Enums\StepResult; -use Codinglabs\Yolo\Contracts\ExecutesWafStep; +use Codinglabs\Yolo\Contracts\ExecutesWebStep; use Codinglabs\Yolo\Resources\WafV2\AllowIpSet; use Codinglabs\Yolo\Concerns\SynchronisesResource; -class SyncWafAllowIpSetStep implements ExecutesWafStep +class SyncWafAllowIpSetStep implements ExecutesWebStep { use SynchronisesResource; diff --git a/src/Steps/Sync/Environment/SyncWafAssociationStep.php b/src/Steps/Sync/Environment/SyncWafAssociationStep.php index 730ffda5..2bdde46a 100644 --- a/src/Steps/Sync/Environment/SyncWafAssociationStep.php +++ b/src/Steps/Sync/Environment/SyncWafAssociationStep.php @@ -8,7 +8,7 @@ use Codinglabs\Yolo\Enums\StepResult; use Codinglabs\Yolo\Resources\WafV2\WebAcl; use Codinglabs\Yolo\Concerns\RecordsChanges; -use Codinglabs\Yolo\Contracts\ExecutesWafStep; +use Codinglabs\Yolo\Contracts\ExecutesWebStep; use Codinglabs\Yolo\Resources\ElbV2\LoadBalancer; /** @@ -19,7 +19,7 @@ * (re)pointed at ours. The change is recorded before the dry-run guard so a * drifted association is reported on the plan and survives to the apply pass. */ -class SyncWafAssociationStep implements ExecutesWafStep +class SyncWafAssociationStep implements ExecutesWebStep { use RecordsChanges; diff --git a/src/Steps/Sync/Environment/SyncWafBlockIpSetStep.php b/src/Steps/Sync/Environment/SyncWafBlockIpSetStep.php index b45e4fd6..d25f88b5 100644 --- a/src/Steps/Sync/Environment/SyncWafBlockIpSetStep.php +++ b/src/Steps/Sync/Environment/SyncWafBlockIpSetStep.php @@ -3,11 +3,11 @@ namespace Codinglabs\Yolo\Steps\Sync\Environment; use Codinglabs\Yolo\Enums\StepResult; -use Codinglabs\Yolo\Contracts\ExecutesWafStep; +use Codinglabs\Yolo\Contracts\ExecutesWebStep; use Codinglabs\Yolo\Resources\WafV2\BlockIpSet; use Codinglabs\Yolo\Concerns\SynchronisesResource; -class SyncWafBlockIpSetStep implements ExecutesWafStep +class SyncWafBlockIpSetStep implements ExecutesWebStep { use SynchronisesResource; diff --git a/src/Steps/Sync/Environment/SyncWafWebAclStep.php b/src/Steps/Sync/Environment/SyncWafWebAclStep.php index e7942719..51b4a433 100644 --- a/src/Steps/Sync/Environment/SyncWafWebAclStep.php +++ b/src/Steps/Sync/Environment/SyncWafWebAclStep.php @@ -4,10 +4,10 @@ use Codinglabs\Yolo\Enums\StepResult; use Codinglabs\Yolo\Resources\WafV2\WebAcl; -use Codinglabs\Yolo\Contracts\ExecutesWafStep; +use Codinglabs\Yolo\Contracts\ExecutesWebStep; use Codinglabs\Yolo\Concerns\SynchronisesResource; -class SyncWafWebAclStep implements ExecutesWafStep +class SyncWafWebAclStep implements ExecutesWebStep { use SynchronisesResource; diff --git a/tests/Unit/Concerns/SkipReasonTest.php b/tests/Unit/Concerns/SkipReasonTest.php index d88d8995..d31a0380 100644 --- a/tests/Unit/Concerns/SkipReasonTest.php +++ b/tests/Unit/Concerns/SkipReasonTest.php @@ -5,7 +5,6 @@ use Codinglabs\Yolo\Enums\StepResult; use Codinglabs\Yolo\Commands\SyncCommand; use Codinglabs\Yolo\Contracts\ExecutesIvsStep; -use Codinglabs\Yolo\Contracts\ExecutesWafStep; use Codinglabs\Yolo\Contracts\ExecutesWebStep; use Codinglabs\Yolo\Contracts\ExecutesSoloStep; use Codinglabs\Yolo\Contracts\ExecutesMultitenancyStep; @@ -96,27 +95,6 @@ function skipReasonFor(object $step): ?string }))->toBeNull(); }); -it('skips WAF steps when waf is not enabled', function (): void { - writeManifest(['account-id' => '111111111111', 'region' => 'ap-southeast-2']); - - expect(skipReasonFor(new class() implements ExecutesWafStep - { - use FakeStepInvoke; - })) - ->toBe('waf not enabled in manifest'); -}); - -it('runs WAF steps when waf is enabled', function (): void { - writeManifest([ - 'account-id' => '111111111111', 'region' => 'ap-southeast-2', 'waf' => true, - ]); - - expect(skipReasonFor(new class() implements ExecutesWafStep - { - use FakeStepInvoke; - }))->toBeNull(); -}); - it('runs a plain step with no structural gate', function (): void { writeManifest(['account-id' => '111111111111', 'region' => 'ap-southeast-2']); diff --git a/tests/Unit/ManifestTest.php b/tests/Unit/ManifestTest.php index 24a8d757..6791fdfa 100644 --- a/tests/Unit/ManifestTest.php +++ b/tests/Unit/ManifestTest.php @@ -208,32 +208,6 @@ }); }); -describe('wafEnabled', function (): void { - it('is false when waf is absent', function (): void { - writeManifest([]); - - expect(Manifest::wafEnabled())->toBeFalse(); - }); - - it('is true when waf is enabled', function (): void { - writeManifest(['waf' => true]); - - expect(Manifest::wafEnabled())->toBeTrue(); - }); - - it('is false when waf is explicitly false', function (): void { - writeManifest(['waf' => false]); - - expect(Manifest::wafEnabled())->toBeFalse(); - }); - - it('is an accepted environment key', function (): void { - writeManifest(['waf' => true]); - - expect(Manifest::unknownKeys())->not->toContain('environments.testing.waf'); - }); -}); - describe('apex', function (): void { it('returns the apex domain', function (): void { writeManifest(['domain' => 'example.com']); diff --git a/tests/Unit/Steps/Waf/WafStepsTest.php b/tests/Unit/Steps/Waf/WafStepsTest.php index 683fb0ab..a50f3641 100644 --- a/tests/Unit/Steps/Waf/WafStepsTest.php +++ b/tests/Unit/Steps/Waf/WafStepsTest.php @@ -5,9 +5,7 @@ use Codinglabs\Yolo\Steps\Sync\Environment\SyncWafAssociationStep; beforeEach(function (): void { - writeManifest([ - 'account-id' => '111111111111', 'region' => 'ap-southeast-2', 'waf' => true, - ]); + writeManifest(['account-id' => '111111111111', 'region' => 'ap-southeast-2']); }); const WAF_WEBACL_ARN = 'arn:aws:wafv2:ap-southeast-2:111:regional/webacl/yolo-testing-waf/acl-id'; From 0fa00fab0a9b3a7ec3bc7e0af4fce8429cd18d59 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 15:44:43 +1000 Subject: [PATCH 05/11] feat(waf): add WAF panels to the CloudWatch dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two panels under a "# WAF" header: request disposition (allowed / blocked / counted) and a blocked-/counted-by-rule breakdown — the latter doubling as the promote-decision view, since the Count-mode managed groups (CRS, SQLi) surface as "would block" before you flip them. The WebACL is env-shared, so it's resolved by a live lookup (not this app's manifest) and the section is omitted until the ACL exists — every app behind the ALB gets the panels regardless of which sync created the WAF. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Resources/CloudWatch/Dashboard.php | 70 +++++++++++++++++++ .../Resources/CloudWatch/DashboardTest.php | 21 ++++++ 2 files changed, 91 insertions(+) diff --git a/src/Resources/CloudWatch/Dashboard.php b/src/Resources/CloudWatch/Dashboard.php index 347dffdf..9c3d1fc1 100644 --- a/src/Resources/CloudWatch/Dashboard.php +++ b/src/Resources/CloudWatch/Dashboard.php @@ -9,8 +9,10 @@ use Illuminate\Support\Str; use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; +use Codinglabs\Yolo\Aws\WafV2; use Codinglabs\Yolo\Aws\CloudFront; use Codinglabs\Yolo\Aws\CloudWatch; +use Codinglabs\Yolo\Resources\WafV2\WebAcl; use Codinglabs\Yolo\Resources\Ecs\EcsCluster; use Codinglabs\Yolo\Resources\Ecs\EcsService; use Codinglabs\Yolo\Resources\S3\AssetBucket; @@ -170,6 +172,10 @@ public function resolveContext(): array 'clusterName' => $web ? (new EcsCluster())->name() : null, 'serviceName' => $web ? (new EcsService())->name() : null, 'albSuffix' => $web ? static::tryResolve(fn (): string => static::loadBalancerDimension((new LoadBalancer())->arn())) : null, + // The WAF is env-shared (one ACL fronts the ALB), so it's looked up live + // rather than derived from this app's manifest — the panel shows for any + // app behind the ALB, and is omitted until the ACL exists. + 'wafWebAcl' => $web ? static::tryResolve(fn (): string => WafV2::webAcl((new WebAcl())->name())['Name']) : null, 'targetGroupSuffix' => $web ? static::tryResolve(fn (): string => static::targetGroupDimension((new TargetGroup())->arn())) : null, 'distributionId' => $web ? static::tryResolve(fn () => CloudFront::distributionByComment((new AssetDistribution())->name())['Id']) : null, 'queuePrefix' => Helpers::keyedResourceName() . '-', @@ -311,6 +317,11 @@ public static function body(array $context): array $widgets = [...$widgets, ...$section]; } + if ($context['wafWebAcl'] !== null) { + [$section, $y] = static::wafSection($context, $y); + $widgets = [...$widgets, ...$section]; + } + [$section, $y] = static::queueSection($context, $y); $widgets = [...$widgets, ...$section]; @@ -529,6 +540,65 @@ protected static function webSection(array $context, int $y): array * @param array $context * @return array{0: array>, 1: int} */ + /** + * The WAF panels: overall allow/block/count posture, and a by-rule breakdown + * that's the promote-decision view — the Count-mode managed groups (CRS, SQLi) + * surface here as "would block" before you flip them to Block. WebACL metrics + * are env-shared, dimensioned on the ACL name + region + rule. + * + * @param array $context + * @return array{0: array>, 1: int} + */ + protected static function wafSection(array $context, int $y): array + { + $region = $context['region']; + $webAcl = $context['wafWebAcl']; + + $series = fn (string $metric, string $rule, array $options): array => [ + 'AWS/WAFV2', $metric, 'WebACL', $webAcl, 'Region', $region, 'Rule', $rule, $options, + ]; + + $widgets = [static::header($y, '# WAF')]; + $y++; + + $widgets[] = static::metric(0, $y, 12, 6, [ + 'title' => 'Request disposition', + 'region' => $region, + 'view' => 'timeSeries', + 'stacked' => false, + 'period' => 60, + 'stat' => 'Sum', + 'metrics' => [ + $series('AllowedRequests', 'ALL', ['label' => 'Allowed', 'color' => static::GREEN]), + $series('BlockedRequests', 'ALL', ['label' => 'Blocked', 'color' => static::RED]), + $series('CountedRequests', 'ALL', ['label' => 'Counted (would block)', 'color' => static::ORANGE]), + ], + ]); + + // Rule names mirror WebAcl's skeleton. The first three Block; the managed + // CRS/SQLi groups ship in Count, so they're charted as CountedRequests — + // climbing counts are the signal to promote them to Block. + $widgets[] = static::metric(12, $y, 12, 6, [ + 'title' => 'Blocked / counted by rule', + 'region' => $region, + 'view' => 'timeSeries', + 'stacked' => true, + 'period' => 60, + 'stat' => 'Sum', + 'metrics' => [ + $series('BlockedRequests', 'yolo-block-ips', ['label' => 'Block list', 'color' => static::RED]), + $series('BlockedRequests', 'AWS-AWSManagedRulesAmazonIpReputationList', ['label' => 'IP reputation']), + $series('BlockedRequests', 'AWS-AWSManagedRulesKnownBadInputsRuleSet', ['label' => 'Known bad inputs']), + $series('BlockedRequests', 'yolo-rate-limit', ['label' => 'Rate limit', 'color' => static::PURPLE]), + $series('CountedRequests', 'AWS-AWSManagedRulesCommonRuleSet', ['label' => 'CRS (count)', 'color' => static::ORANGE]), + $series('CountedRequests', 'AWS-AWSManagedRulesSQLiRuleSet', ['label' => 'SQLi (count)']), + ], + ]); + $y += 6; + + return [$widgets, $y]; + } + protected static function queueSection(array $context, int $y): array { $region = $context['region']; diff --git a/tests/Unit/Resources/CloudWatch/DashboardTest.php b/tests/Unit/Resources/CloudWatch/DashboardTest.php index 8025004a..7a785954 100644 --- a/tests/Unit/Resources/CloudWatch/DashboardTest.php +++ b/tests/Unit/Resources/CloudWatch/DashboardTest.php @@ -36,6 +36,7 @@ function dashboardContext(array $overrides = []): array 'buckets' => ['yolo-testing-my-app-artefacts', 'yolo-testing-my-app-assets'], 'taskLogGroup' => '/yolo/testing-my-app', 'ivsLogGroup' => null, + 'wafWebAcl' => null, 'depthThreshold' => 100, ], $overrides); } @@ -229,6 +230,26 @@ public function __invoke(CommandInterface $cmd, $request) expect($ivs['properties']['query'])->toContain("SOURCE '/aws/ivs/testing-my-app'"); }); +it('omits the WAF panels until the web ACL exists', function (): void { + expect(widgetTitles(Dashboard::body(dashboardContext())))->not->toContain('# WAF'); +}); + +it('adds WAF panels dimensioned on the env web ACL when it exists', function (): void { + $body = Dashboard::body(dashboardContext(['wafWebAcl' => 'yolo-testing-waf'])); + + expect(widgetTitles($body))->toContain('# WAF'); + + $requests = findWidget($body, 'Request disposition'); + $metric = collect($requests['properties']['metrics'])->first(fn (array $m): bool => $m[1] === 'BlockedRequests'); + + // [namespace, metric, WebACL, , Region, , Rule, ALL, {options}] + expect($metric)->toContain('AWS/WAFV2', 'yolo-testing-waf', 'ap-southeast-2', 'ALL'); + + $byRule = findWidget($body, 'Blocked / counted by rule'); + expect(collect($byRule['properties']['metrics'])->pluck(7)) + ->toContain('yolo-block-ips', 'yolo-rate-limit', 'AWS-AWSManagedRulesCommonRuleSet'); +}); + it('creates the dashboard when it does not exist (apply) and reports WOULD_CREATE on a dry-run', function (): void { bindDashboardEnv("APP_ENV=production\n"); From e4c90e539e7b76b1f9c22611c08f600489446fb5 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 16:08:18 +1000 Subject: [PATCH 06/11] feat(waf): add the PHP managed rule group to the baseline (Count) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP/Laravel-targeted managed group, Count-first like CRS/SQLi — an oversight in the initial skeleton for a PHP-focused deployer. Surfaces on the dashboard's counted-by-rule panel alongside CRS and SQLi. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/provisioning.md | 1 + src/Resources/CloudWatch/Dashboard.php | 1 + src/Resources/WafV2/WebAcl.php | 5 +++-- tests/Unit/Resources/WafV2/WebAclTest.php | 4 +++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index 11fea2fe..a8aa4296 100644 --- a/docs/guide/provisioning.md +++ b/docs/guide/provisioning.md @@ -50,6 +50,7 @@ The default skeleton, in priority order: | Known bad inputs | Block | Low false-positive; auto-evolves. | | Core rule set (CRS) | **Count** | Ships in Count so a new AWS signature can't start blocking live traffic unannounced — promote to Block once you've watched the metrics. | | SQL injection | **Count** | Same Count-first treatment. | +| PHP application | **Count** | PHP/Laravel-targeted; same Count-first treatment. | | Rate limit | Block | ~2000 requests / 5 min **per source IP**. | The managed groups are referenced **unversioned**, so AWS's signature and IP-reputation updates roll in automatically — the WAF improves over time without a YOLO change. The IP sets are **create-only**: an IP you add in the console survives every subsequent `sync`. A rule you add by hand (matched by name) is preserved too — YOLO only ever rewrites the rules it owns. diff --git a/src/Resources/CloudWatch/Dashboard.php b/src/Resources/CloudWatch/Dashboard.php index 9c3d1fc1..898f37ba 100644 --- a/src/Resources/CloudWatch/Dashboard.php +++ b/src/Resources/CloudWatch/Dashboard.php @@ -592,6 +592,7 @@ protected static function wafSection(array $context, int $y): array $series('BlockedRequests', 'yolo-rate-limit', ['label' => 'Rate limit', 'color' => static::PURPLE]), $series('CountedRequests', 'AWS-AWSManagedRulesCommonRuleSet', ['label' => 'CRS (count)', 'color' => static::ORANGE]), $series('CountedRequests', 'AWS-AWSManagedRulesSQLiRuleSet', ['label' => 'SQLi (count)']), + $series('CountedRequests', 'AWS-AWSManagedRulesPHPRuleSet', ['label' => 'PHP (count)']), ], ]); $y += 6; diff --git a/src/Resources/WafV2/WebAcl.php b/src/Resources/WafV2/WebAcl.php index 173b3f34..1876220d 100644 --- a/src/Resources/WafV2/WebAcl.php +++ b/src/Resources/WafV2/WebAcl.php @@ -164,8 +164,8 @@ public function desiredRules(): array /** * AWS managed rule groups, referenced unversioned so they track the latest * signatures. The low-false-positive groups override to None (the group's own - * Block actions apply); CRS and SQLi override to Count so they observe without - * blocking until an operator promotes them. + * Block actions apply); the broad content groups (CRS, SQLi, PHP) override to + * Count so they observe without blocking until an operator promotes them. * * @return array> */ @@ -176,6 +176,7 @@ protected function managedGroupRules(): array ['name' => 'AWSManagedRulesKnownBadInputsRuleSet', 'priority' => 11, 'override' => 'None'], ['name' => 'AWSManagedRulesCommonRuleSet', 'priority' => 12, 'override' => 'Count'], ['name' => 'AWSManagedRulesSQLiRuleSet', 'priority' => 13, 'override' => 'Count'], + ['name' => 'AWSManagedRulesPHPRuleSet', 'priority' => 14, 'override' => 'Count'], ]; return array_map(fn (array $group): array => [ diff --git a/tests/Unit/Resources/WafV2/WebAclTest.php b/tests/Unit/Resources/WafV2/WebAclTest.php index 2222f47d..9d38721b 100644 --- a/tests/Unit/Resources/WafV2/WebAclTest.php +++ b/tests/Unit/Resources/WafV2/WebAclTest.php @@ -35,13 +35,15 @@ 'AWS-AWSManagedRulesKnownBadInputsRuleSet', 'AWS-AWSManagedRulesCommonRuleSet', 'AWS-AWSManagedRulesSQLiRuleSet', + 'AWS-AWSManagedRulesPHPRuleSet', 'yolo-rate-limit', ]); - // The noisy managed groups ship in Count; the low-false-positive ones block. + // The broad content groups ship in Count; the low-false-positive ones block. $byName = collect($create['args']['Rules'])->keyBy('Name'); expect($byName['AWS-AWSManagedRulesCommonRuleSet']['OverrideAction'])->toBe(['Count' => []]) ->and($byName['AWS-AWSManagedRulesSQLiRuleSet']['OverrideAction'])->toBe(['Count' => []]) + ->and($byName['AWS-AWSManagedRulesPHPRuleSet']['OverrideAction'])->toBe(['Count' => []]) ->and($byName['AWS-AWSManagedRulesAmazonIpReputationList']['OverrideAction'])->toBe(['None' => []]) ->and($byName['AWS-AWSManagedRulesKnownBadInputsRuleSet']['OverrideAction'])->toBe(['None' => []]); From 1a56eb74c3874f4d63a62b3188fd230d457c5bd2 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 16:15:56 +1000 Subject: [PATCH 07/11] feat(waf): match LP's DoS rate limit + seed a default country block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rate limit retuned to LP's proven DoS config: 200 requests per rolling 1-minute window per source IP (was 2000/5min). - New default country block (Block) seeded with a high-risk list (CN/GH/KP/LB/NG/RU/BD/NP/IQ/IR/CI/BO) as a starting point. It's seed-only — created once, then operator-owned, so re-scoping the countries survives every sync (the WebACL reconcile only touches YOLO-owned rules). Mirrors the create-only IP-set model for a rule whose content can't live in its own resource. - Dashboard by-rule panel gains a Geo block series. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/provisioning.md | 5 +- src/Resources/CloudWatch/Dashboard.php | 1 + src/Resources/WafV2/WebAcl.php | 71 ++++++++++++++++++++++- tests/Unit/Resources/WafV2/WebAclTest.php | 38 ++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index a8aa4296..a0bd5835 100644 --- a/docs/guide/provisioning.md +++ b/docs/guide/provisioning.md @@ -46,14 +46,15 @@ The default skeleton, in priority order: | --- | --- | --- | | Allow IP set | Allow | Seeded **empty** — add known-good IPs (e.g. crawler ranges a managed group might false-positive). | | Block IP set | Block | Seeded **empty** — the lever for shutting down an abusive source mid-incident. | +| Country block | Block | Seeded with a default high-risk list (CN, RU, KP, IR, BD, …) as a starting point — **seed-only**, so re-scope it per app and sync won't revert you. | | Amazon IP reputation list | Block | Low false-positive; auto-evolves. | | Known bad inputs | Block | Low false-positive; auto-evolves. | | Core rule set (CRS) | **Count** | Ships in Count so a new AWS signature can't start blocking live traffic unannounced — promote to Block once you've watched the metrics. | | SQL injection | **Count** | Same Count-first treatment. | | PHP application | **Count** | PHP/Laravel-targeted; same Count-first treatment. | -| Rate limit | Block | ~2000 requests / 5 min **per source IP**. | +| Rate limit (DoS) | Block | **200 requests / 1 min** per source IP. | -The managed groups are referenced **unversioned**, so AWS's signature and IP-reputation updates roll in automatically — the WAF improves over time without a YOLO change. The IP sets are **create-only**: an IP you add in the console survives every subsequent `sync`. A rule you add by hand (matched by name) is preserved too — YOLO only ever rewrites the rules it owns. +The managed groups are referenced **unversioned**, so AWS's signature and IP-reputation updates roll in automatically — the WAF improves over time without a YOLO change. The IP sets are **create-only**: an IP you add in the console survives every subsequent `sync`. The country block is **seed-only** in the same spirit — laid down once with a sensible default list, then yours to re-scope. And a rule you add by hand (matched by name) is preserved too — YOLO only ever rewrites the rules it owns. ## Plan, confirm, apply diff --git a/src/Resources/CloudWatch/Dashboard.php b/src/Resources/CloudWatch/Dashboard.php index 898f37ba..dc273e27 100644 --- a/src/Resources/CloudWatch/Dashboard.php +++ b/src/Resources/CloudWatch/Dashboard.php @@ -587,6 +587,7 @@ protected static function wafSection(array $context, int $y): array 'stat' => 'Sum', 'metrics' => [ $series('BlockedRequests', 'yolo-block-ips', ['label' => 'Block list', 'color' => static::RED]), + $series('BlockedRequests', 'yolo-banned-countries', ['label' => 'Geo block', 'color' => static::BLUE]), $series('BlockedRequests', 'AWS-AWSManagedRulesAmazonIpReputationList', ['label' => 'IP reputation']), $series('BlockedRequests', 'AWS-AWSManagedRulesKnownBadInputsRuleSet', ['label' => 'Known bad inputs']), $series('BlockedRequests', 'yolo-rate-limit', ['label' => 'Rate limit', 'color' => static::PURPLE]), diff --git a/src/Resources/WafV2/WebAcl.php b/src/Resources/WafV2/WebAcl.php index 1876220d..b4afb1cf 100644 --- a/src/Resources/WafV2/WebAcl.php +++ b/src/Resources/WafV2/WebAcl.php @@ -30,8 +30,21 @@ class WebAcl implements Resource, SynchronisesConfiguration { use ResolvesTags; - /** Per-IP request ceiling over the rolling 5-minute window. */ - private const int RATE_LIMIT = 2000; + /** Per-IP request ceiling, evaluated over a rolling 1-minute window. */ + private const int RATE_LIMIT = 200; + + private const int RATE_WINDOW_SECONDS = 60; + + /** + * High-risk geographies blocked by default — a hardcoded starting point an + * operator fine-tunes per app. Seeded once on create and then operator-owned + * (see seededRules()), so edits survive every sync. + * + * @var array + */ + private const array BANNED_COUNTRIES = [ + 'CN', 'GH', 'KP', 'LB', 'NG', 'RU', 'BD', 'NP', 'IQ', 'IR', 'CI', 'BO', + ]; private const string ALLOW_RULE = 'yolo-allow-ips'; @@ -39,6 +52,8 @@ class WebAcl implements Resource, SynchronisesConfiguration private const string RATE_RULE = 'yolo-rate-limit'; + private const string COUNTRY_RULE = 'yolo-banned-countries'; + public function name(): string { return $this->keyedName('waf'); @@ -72,12 +87,41 @@ public function create(): void 'Scope' => WafV2::SCOPE, 'Description' => 'YOLO managed WAF for the environment load balancer', 'DefaultAction' => $this->defaultAction(), - 'Rules' => $this->desiredRules(), + 'Rules' => $this->creationRules(), 'VisibilityConfig' => $this->visibilityConfig($this->name()), ...Aws::tags($this->tags()), ]); } + /** + * The full rule set written at create time, priority-ordered: the reconciled + * skeleton plus the seed-only rules (the country block) that are operator-owned + * thereafter. Reconcile (synchroniseConfiguration) only ever touches + * desiredRules(), so the seeds are laid down once and then left alone. + * + * @return array> + */ + protected function creationRules(): array + { + return collect([...$this->desiredRules(), ...$this->seededRules()]) + ->sortBy('Priority') + ->values() + ->all(); + } + + /** + * Rules YOLO seeds once on create and never reconciles — a hardcoded starting + * point the operator then owns (like the empty allow/block IP sets, but for a + * rule whose content can't live in a separate resource). The country block + * lives here so an operator can re-scope it without sync reverting them. + * + * @return array> + */ + protected function seededRules(): array + { + return [$this->bannedCountriesRule()]; + } + public function synchroniseTags(bool $apply): array { return Aws::synchroniseWafV2Tags($this->arn(), $this->tags(), $apply); @@ -222,12 +266,33 @@ protected function rateLimitRule(): array 'RateBasedStatement' => [ 'Limit' => self::RATE_LIMIT, 'AggregateKeyType' => 'IP', + 'EvaluationWindowSec' => self::RATE_WINDOW_SECONDS, ], ], 'VisibilityConfig' => $this->visibilityConfig(self::RATE_RULE), ]; } + /** + * The default country block (seed-only — see seededRules()). Action Block, a + * geo-match on the hardcoded high-risk list; the operator re-scopes the + * countries afterwards and sync never reverts them. + * + * @return array + */ + protected function bannedCountriesRule(): array + { + return [ + 'Name' => self::COUNTRY_RULE, + 'Priority' => 2, + 'Action' => ['Block' => []], + 'Statement' => [ + 'GeoMatchStatement' => ['CountryCodes' => self::BANNED_COUNTRIES], + ], + 'VisibilityConfig' => $this->visibilityConfig(self::COUNTRY_RULE), + ]; + } + /** * Live rules YOLO doesn't own (matched by Name) — preserved verbatim through * an update so an operator's hand-rolled rules are never clobbered. diff --git a/tests/Unit/Resources/WafV2/WebAclTest.php b/tests/Unit/Resources/WafV2/WebAclTest.php index 9d38721b..c3f5ad04 100644 --- a/tests/Unit/Resources/WafV2/WebAclTest.php +++ b/tests/Unit/Resources/WafV2/WebAclTest.php @@ -28,9 +28,11 @@ $ruleNames = array_column($create['args']['Rules'], 'Name'); + // Priority order: allow, block, country block, managed groups, rate limit. expect($ruleNames)->toBe([ 'yolo-allow-ips', 'yolo-block-ips', + 'yolo-banned-countries', 'AWS-AWSManagedRulesAmazonIpReputationList', 'AWS-AWSManagedRulesKnownBadInputsRuleSet', 'AWS-AWSManagedRulesCommonRuleSet', @@ -51,9 +53,45 @@ expect($byName['AWS-AWSManagedRulesCommonRuleSet']['Statement']['ManagedRuleGroupStatement']) ->not->toHaveKey('Version'); + // DoS rate limit: 200 requests per rolling 1-minute window, per source IP. + expect($byName['yolo-rate-limit']['Action'])->toBe(['Block' => []]) + ->and($byName['yolo-rate-limit']['Statement']['RateBasedStatement']) + ->toMatchArray(['Limit' => 200, 'AggregateKeyType' => 'IP', 'EvaluationWindowSec' => 60]); + + // Country block seeded with the default high-risk list, action Block. + expect($byName['yolo-banned-countries']['Action'])->toBe(['Block' => []]) + ->and($byName['yolo-banned-countries']['Statement']['GeoMatchStatement']['CountryCodes']) + ->toContain('CN', 'RU', 'KP', 'IR', 'BD'); + expect($create['args']['Tags'])->toContain(['Key' => 'yolo:scope', 'Value' => 'env']); }); +it('seeds the country block but never reconciles it — operator owns it after create', function (): void { + // Live ACL where an operator has re-scoped the country block to a single country. + $operatorTuned = [ + ...desiredWafRules(), + [ + 'Name' => 'yolo-banned-countries', + 'Priority' => 2, + 'Action' => ['Block' => []], + 'Statement' => ['GeoMatchStatement' => ['CountryCodes' => ['CN']]], + 'VisibilityConfig' => ['SampledRequestsEnabled' => true, 'CloudWatchMetricsEnabled' => true, 'MetricName' => 'yolo-banned-countries'], + ], + ]; + + $captured = []; + bindRoutedWafV2Client([ + 'ListIPSets' => wafIpSetsResult(), + 'ListWebACLs' => wafWebAclsResult(), + 'GetWebACL' => liveWebAclResult($operatorTuned), + ], $captured); + + // No drift: the country rule isn't YOLO-owned, so its re-scoping is invisible + // to reconcile and never rewritten. + expect((new WebAcl())->synchroniseConfiguration())->toBe([]); + expect(array_column($captured, 'name'))->not->toContain('UpdateWebACL'); +}); + it('reports exists from the live web ACL list', function (): void { $captured = []; bindRoutedWafV2Client(['ListWebACLs' => wafWebAclsResult()], $captured); From dfa9864e310b34c7e4a58d86f5e0c72684e46dcb Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 16:35:19 +1000 Subject: [PATCH 08/11] feat(waf): block SQLi + PHP outright, leave only CRS in Count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLi and PHP are targeted, low-false-positive managed groups — there's no reason to observe them first, so they override to None (the group's own Block actions apply). Only the broad Core Rule Set stays in Count, since it's the one whose fresh signatures can plausibly false-positive on legit traffic. Dashboard moves SQLi/PHP into the blocked-by-rule series; CRS remains the lone counted series. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/provisioning.md | 6 +++--- src/Resources/CloudWatch/Dashboard.php | 14 +++++++------- src/Resources/WafV2/WebAcl.php | 11 ++++++----- tests/Unit/Resources/WafV2/WebAclTest.php | 6 +++--- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index a0bd5835..64e4c25b 100644 --- a/docs/guide/provisioning.md +++ b/docs/guide/provisioning.md @@ -49,9 +49,9 @@ The default skeleton, in priority order: | Country block | Block | Seeded with a default high-risk list (CN, RU, KP, IR, BD, …) as a starting point — **seed-only**, so re-scope it per app and sync won't revert you. | | Amazon IP reputation list | Block | Low false-positive; auto-evolves. | | Known bad inputs | Block | Low false-positive; auto-evolves. | -| Core rule set (CRS) | **Count** | Ships in Count so a new AWS signature can't start blocking live traffic unannounced — promote to Block once you've watched the metrics. | -| SQL injection | **Count** | Same Count-first treatment. | -| PHP application | **Count** | PHP/Laravel-targeted; same Count-first treatment. | +| Core rule set (CRS) | **Count** | The one broad group — ships in Count so a new AWS signature can't start blocking live traffic unannounced. Promote to Block once you've watched the metrics. | +| SQL injection | Block | Targeted SQLi signatures; low false-positive, so it blocks outright. | +| PHP application | Block | PHP/Laravel exploit signatures; blocks outright. | | Rate limit (DoS) | Block | **200 requests / 1 min** per source IP. | The managed groups are referenced **unversioned**, so AWS's signature and IP-reputation updates roll in automatically — the WAF improves over time without a YOLO change. The IP sets are **create-only**: an IP you add in the console survives every subsequent `sync`. The country block is **seed-only** in the same spirit — laid down once with a sensible default list, then yours to re-scope. And a rule you add by hand (matched by name) is preserved too — YOLO only ever rewrites the rules it owns. diff --git a/src/Resources/CloudWatch/Dashboard.php b/src/Resources/CloudWatch/Dashboard.php index dc273e27..b1d5b46d 100644 --- a/src/Resources/CloudWatch/Dashboard.php +++ b/src/Resources/CloudWatch/Dashboard.php @@ -542,8 +542,8 @@ protected static function webSection(array $context, int $y): array */ /** * The WAF panels: overall allow/block/count posture, and a by-rule breakdown - * that's the promote-decision view — the Count-mode managed groups (CRS, SQLi) - * surface here as "would block" before you flip them to Block. WebACL metrics + * that's the promote-decision view — the Count-mode group (the broad CRS) + * surfaces here as "would block" before you flip it to Block. WebACL metrics * are env-shared, dimensioned on the ACL name + region + rule. * * @param array $context @@ -575,9 +575,9 @@ protected static function wafSection(array $context, int $y): array ], ]); - // Rule names mirror WebAcl's skeleton. The first three Block; the managed - // CRS/SQLi groups ship in Count, so they're charted as CountedRequests — - // climbing counts are the signal to promote them to Block. + // Rule names mirror WebAcl's skeleton. Everything blocks except the broad + // CRS, which ships in Count and is charted as CountedRequests — a climbing + // count is the signal it's safe to promote to Block. $widgets[] = static::metric(12, $y, 12, 6, [ 'title' => 'Blocked / counted by rule', 'region' => $region, @@ -590,10 +590,10 @@ protected static function wafSection(array $context, int $y): array $series('BlockedRequests', 'yolo-banned-countries', ['label' => 'Geo block', 'color' => static::BLUE]), $series('BlockedRequests', 'AWS-AWSManagedRulesAmazonIpReputationList', ['label' => 'IP reputation']), $series('BlockedRequests', 'AWS-AWSManagedRulesKnownBadInputsRuleSet', ['label' => 'Known bad inputs']), + $series('BlockedRequests', 'AWS-AWSManagedRulesSQLiRuleSet', ['label' => 'SQLi']), + $series('BlockedRequests', 'AWS-AWSManagedRulesPHPRuleSet', ['label' => 'PHP']), $series('BlockedRequests', 'yolo-rate-limit', ['label' => 'Rate limit', 'color' => static::PURPLE]), $series('CountedRequests', 'AWS-AWSManagedRulesCommonRuleSet', ['label' => 'CRS (count)', 'color' => static::ORANGE]), - $series('CountedRequests', 'AWS-AWSManagedRulesSQLiRuleSet', ['label' => 'SQLi (count)']), - $series('CountedRequests', 'AWS-AWSManagedRulesPHPRuleSet', ['label' => 'PHP (count)']), ], ]); $y += 6; diff --git a/src/Resources/WafV2/WebAcl.php b/src/Resources/WafV2/WebAcl.php index b4afb1cf..b98b0e8d 100644 --- a/src/Resources/WafV2/WebAcl.php +++ b/src/Resources/WafV2/WebAcl.php @@ -207,9 +207,10 @@ public function desiredRules(): array /** * AWS managed rule groups, referenced unversioned so they track the latest - * signatures. The low-false-positive groups override to None (the group's own - * Block actions apply); the broad content groups (CRS, SQLi, PHP) override to - * Count so they observe without blocking until an operator promotes them. + * signatures. The targeted, low-false-positive groups override to None (the + * group's own Block actions apply); only the broad Core Rule Set overrides to + * Count, so a new AWS signature can't start blocking live traffic unannounced + * — promote it to Block (override None) once its metrics look clean. * * @return array> */ @@ -219,8 +220,8 @@ protected function managedGroupRules(): array ['name' => 'AWSManagedRulesAmazonIpReputationList', 'priority' => 10, 'override' => 'None'], ['name' => 'AWSManagedRulesKnownBadInputsRuleSet', 'priority' => 11, 'override' => 'None'], ['name' => 'AWSManagedRulesCommonRuleSet', 'priority' => 12, 'override' => 'Count'], - ['name' => 'AWSManagedRulesSQLiRuleSet', 'priority' => 13, 'override' => 'Count'], - ['name' => 'AWSManagedRulesPHPRuleSet', 'priority' => 14, 'override' => 'Count'], + ['name' => 'AWSManagedRulesSQLiRuleSet', 'priority' => 13, 'override' => 'None'], + ['name' => 'AWSManagedRulesPHPRuleSet', 'priority' => 14, 'override' => 'None'], ]; return array_map(fn (array $group): array => [ diff --git a/tests/Unit/Resources/WafV2/WebAclTest.php b/tests/Unit/Resources/WafV2/WebAclTest.php index c3f5ad04..81bbb768 100644 --- a/tests/Unit/Resources/WafV2/WebAclTest.php +++ b/tests/Unit/Resources/WafV2/WebAclTest.php @@ -41,11 +41,11 @@ 'yolo-rate-limit', ]); - // The broad content groups ship in Count; the low-false-positive ones block. + // Only the broad CRS ships in Count; the targeted groups block outright. $byName = collect($create['args']['Rules'])->keyBy('Name'); expect($byName['AWS-AWSManagedRulesCommonRuleSet']['OverrideAction'])->toBe(['Count' => []]) - ->and($byName['AWS-AWSManagedRulesSQLiRuleSet']['OverrideAction'])->toBe(['Count' => []]) - ->and($byName['AWS-AWSManagedRulesPHPRuleSet']['OverrideAction'])->toBe(['Count' => []]) + ->and($byName['AWS-AWSManagedRulesSQLiRuleSet']['OverrideAction'])->toBe(['None' => []]) + ->and($byName['AWS-AWSManagedRulesPHPRuleSet']['OverrideAction'])->toBe(['None' => []]) ->and($byName['AWS-AWSManagedRulesAmazonIpReputationList']['OverrideAction'])->toBe(['None' => []]) ->and($byName['AWS-AWSManagedRulesKnownBadInputsRuleSet']['OverrideAction'])->toBe(['None' => []]); From 167b84d1af636a44ca8f9299be4aac05d33c196e Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 16:35:57 +1000 Subject: [PATCH 09/11] feat(waf): drop Bolivia from the default country block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BO isn't a recognised scraping/fraud/botnet origin — it was an LP-specific empirical addition that doesn't generalise to a YOLO default. Keep it on LP via the per-platform tuning, not the hardcoded baseline. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Resources/WafV2/WebAcl.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Resources/WafV2/WebAcl.php b/src/Resources/WafV2/WebAcl.php index b98b0e8d..a8620065 100644 --- a/src/Resources/WafV2/WebAcl.php +++ b/src/Resources/WafV2/WebAcl.php @@ -43,7 +43,7 @@ class WebAcl implements Resource, SynchronisesConfiguration * @var array */ private const array BANNED_COUNTRIES = [ - 'CN', 'GH', 'KP', 'LB', 'NG', 'RU', 'BD', 'NP', 'IQ', 'IR', 'CI', 'BO', + 'CN', 'GH', 'KP', 'LB', 'NG', 'RU', 'BD', 'NP', 'IQ', 'IR', 'CI', ]; private const string ALLOW_RULE = 'yolo-allow-ips'; From c82949e7ce3919745abd70b1dfc02f9b6f0620f0 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 16:42:25 +1000 Subject: [PATCH 10/11] feat(waf): block CRS too, carve out the 8 KB body-size cap; sanitise docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRS now blocks (override None) like the other groups, with a RuleActionOverride dropping SizeRestrictions_BODY to Count: its 8 KB request-body cap would block legitimate large POSTs that don't go direct-to-S3, a universal false-positive we'd rather observe than enforce. Nothing is left in Count by default. - Dashboard by-rule panel now charts every group as blocked. - Docs: the WAF section no longer publishes the country list, rate threshold, or per-rule actions — high-level only, so the public reference isn't an evasion map. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/provisioning.md | 26 +++--------- src/Resources/CloudWatch/Dashboard.php | 17 ++++---- src/Resources/WafV2/WebAcl.php | 41 +++++++++++++------ .../Resources/CloudWatch/DashboardTest.php | 2 +- tests/Unit/Resources/WafV2/WebAclTest.php | 11 ++++- 5 files changed, 53 insertions(+), 44 deletions(-) diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index 64e4c25b..c6bf7092 100644 --- a/docs/guide/provisioning.md +++ b/docs/guide/provisioning.md @@ -32,29 +32,15 @@ Several apps can share one environment's VPC and load balancer. Because `sync:ap ## Web application firewall -Every environment with a load balancer gets a regional [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html) web ACL on its ALB — automatically, with no manifest key. It's compulsory infrastructure, like the ALB itself: one web ACL protects every app sharing the load balancer, and there's no reason for an app to opt out (an app with genuinely incompatible needs belongs on its own load balancer or account). +Every environment with a load balancer gets a managed [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html) web ACL on its ALB — automatically, with no manifest key. It's compulsory infrastructure, like the ALB itself: one web ACL protects every app sharing the load balancer. -YOLO owns the **policy skeleton** and reconciles it on every sync; you own the **list contents**, which sync never touches: +YOLO owns the **policy** — a baseline of AWS-managed protections, a per-IP rate limit, and a high-risk-country block — and reconciles it on every sync. You own the **operational lists**: -| YOLO reconciles (declarative) | You manage (console, never reconciled) | -| --- | --- | -| Default action (`Allow`), the allow/block rule wiring, the AWS managed rule groups and their actions, the per-IP rate limit | The CIDRs inside the allow / block IP sets, and any rule you add by hand | +- The **allow** and **block IP sets** are seeded empty for you to fill (known-good IPs to allow; abusive sources to block). Their contents are **create-only** — an entry you add in the console survives every subsequent `sync`. +- The **country block** is seeded with a sensible default and is likewise yours to re-scope; it's **seed-only**, so your edits stick. +- Any **rule you add by hand** is preserved too — YOLO only ever rewrites the rules it owns. -The default skeleton, in priority order: - -| Rule | Action | Notes | -| --- | --- | --- | -| Allow IP set | Allow | Seeded **empty** — add known-good IPs (e.g. crawler ranges a managed group might false-positive). | -| Block IP set | Block | Seeded **empty** — the lever for shutting down an abusive source mid-incident. | -| Country block | Block | Seeded with a default high-risk list (CN, RU, KP, IR, BD, …) as a starting point — **seed-only**, so re-scope it per app and sync won't revert you. | -| Amazon IP reputation list | Block | Low false-positive; auto-evolves. | -| Known bad inputs | Block | Low false-positive; auto-evolves. | -| Core rule set (CRS) | **Count** | The one broad group — ships in Count so a new AWS signature can't start blocking live traffic unannounced. Promote to Block once you've watched the metrics. | -| SQL injection | Block | Targeted SQLi signatures; low false-positive, so it blocks outright. | -| PHP application | Block | PHP/Laravel exploit signatures; blocks outright. | -| Rate limit (DoS) | Block | **200 requests / 1 min** per source IP. | - -The managed groups are referenced **unversioned**, so AWS's signature and IP-reputation updates roll in automatically — the WAF improves over time without a YOLO change. The IP sets are **create-only**: an IP you add in the console survives every subsequent `sync`. The country block is **seed-only** in the same spirit — laid down once with a sensible default list, then yours to re-scope. And a rule you add by hand (matched by name) is preserved too — YOLO only ever rewrites the rules it owns. +Tune the rest in the AWS console; YOLO won't undo it. ## Plan, confirm, apply diff --git a/src/Resources/CloudWatch/Dashboard.php b/src/Resources/CloudWatch/Dashboard.php index b1d5b46d..d6f92b19 100644 --- a/src/Resources/CloudWatch/Dashboard.php +++ b/src/Resources/CloudWatch/Dashboard.php @@ -541,10 +541,10 @@ protected static function webSection(array $context, int $y): array * @return array{0: array>, 1: int} */ /** - * The WAF panels: overall allow/block/count posture, and a by-rule breakdown - * that's the promote-decision view — the Count-mode group (the broad CRS) - * surfaces here as "would block" before you flip it to Block. WebACL metrics - * are env-shared, dimensioned on the ACL name + region + rule. + * The WAF panels: overall allow/block/count posture, and a per-rule blocked + * breakdown showing where blocks come from. The disposition panel's Counted + * series picks up anything left in Count (the Core Rule Set's body-size + * carve-out). WebACL metrics are env-shared, dimensioned on ACL + region + rule. * * @param array $context * @return array{0: array>, 1: int} @@ -575,11 +575,10 @@ protected static function wafSection(array $context, int $y): array ], ]); - // Rule names mirror WebAcl's skeleton. Everything blocks except the broad - // CRS, which ships in Count and is charted as CountedRequests — a climbing - // count is the signal it's safe to promote to Block. + // Rule names mirror WebAcl's skeleton — every group blocks, so each is + // charted as BlockedRequests showing where blocks originate. $widgets[] = static::metric(12, $y, 12, 6, [ - 'title' => 'Blocked / counted by rule', + 'title' => 'Blocked by rule', 'region' => $region, 'view' => 'timeSeries', 'stacked' => true, @@ -590,10 +589,10 @@ protected static function wafSection(array $context, int $y): array $series('BlockedRequests', 'yolo-banned-countries', ['label' => 'Geo block', 'color' => static::BLUE]), $series('BlockedRequests', 'AWS-AWSManagedRulesAmazonIpReputationList', ['label' => 'IP reputation']), $series('BlockedRequests', 'AWS-AWSManagedRulesKnownBadInputsRuleSet', ['label' => 'Known bad inputs']), + $series('BlockedRequests', 'AWS-AWSManagedRulesCommonRuleSet', ['label' => 'CRS', 'color' => static::ORANGE]), $series('BlockedRequests', 'AWS-AWSManagedRulesSQLiRuleSet', ['label' => 'SQLi']), $series('BlockedRequests', 'AWS-AWSManagedRulesPHPRuleSet', ['label' => 'PHP']), $series('BlockedRequests', 'yolo-rate-limit', ['label' => 'Rate limit', 'color' => static::PURPLE]), - $series('CountedRequests', 'AWS-AWSManagedRulesCommonRuleSet', ['label' => 'CRS (count)', 'color' => static::ORANGE]), ], ]); $y += 6; diff --git a/src/Resources/WafV2/WebAcl.php b/src/Resources/WafV2/WebAcl.php index a8620065..285102c7 100644 --- a/src/Resources/WafV2/WebAcl.php +++ b/src/Resources/WafV2/WebAcl.php @@ -207,37 +207,54 @@ public function desiredRules(): array /** * AWS managed rule groups, referenced unversioned so they track the latest - * signatures. The targeted, low-false-positive groups override to None (the - * group's own Block actions apply); only the broad Core Rule Set overrides to - * Count, so a new AWS signature can't start blocking live traffic unannounced - * — promote it to Block (override None) once its metrics look clean. + * signatures. Every group blocks (override None — the group's own Block actions + * apply), with one carve-out: the Core Rule Set's SizeRestrictions_BODY sub-rule + * is dropped to Count, because its 8 KB request-body cap would block legitimate + * large POSTs that don't go direct-to-S3 — a universal false-positive we'd + * rather observe than enforce. Per-sub-rule action overrides are declared here. * * @return array> */ protected function managedGroupRules(): array { $groups = [ - ['name' => 'AWSManagedRulesAmazonIpReputationList', 'priority' => 10, 'override' => 'None'], - ['name' => 'AWSManagedRulesKnownBadInputsRuleSet', 'priority' => 11, 'override' => 'None'], - ['name' => 'AWSManagedRulesCommonRuleSet', 'priority' => 12, 'override' => 'Count'], - ['name' => 'AWSManagedRulesSQLiRuleSet', 'priority' => 13, 'override' => 'None'], - ['name' => 'AWSManagedRulesPHPRuleSet', 'priority' => 14, 'override' => 'None'], + ['name' => 'AWSManagedRulesAmazonIpReputationList', 'priority' => 10], + ['name' => 'AWSManagedRulesKnownBadInputsRuleSet', 'priority' => 11], + ['name' => 'AWSManagedRulesCommonRuleSet', 'priority' => 12, 'ruleOverrides' => ['SizeRestrictions_BODY' => 'Count']], + ['name' => 'AWSManagedRulesSQLiRuleSet', 'priority' => 13], + ['name' => 'AWSManagedRulesPHPRuleSet', 'priority' => 14], ]; return array_map(fn (array $group): array => [ 'Name' => 'AWS-' . $group['name'], 'Priority' => $group['priority'], - 'OverrideAction' => [$group['override'] => []], + 'OverrideAction' => ['None' => []], 'Statement' => [ - 'ManagedRuleGroupStatement' => [ + 'ManagedRuleGroupStatement' => array_filter([ 'VendorName' => 'AWS', 'Name' => $group['name'], - ], + 'RuleActionOverrides' => $this->ruleActionOverrides($group['ruleOverrides'] ?? []), + ]), ], 'VisibilityConfig' => $this->visibilityConfig('AWS-' . $group['name']), ], $groups); } + /** + * Translate a [sub-rule => action] map into the WAFv2 RuleActionOverrides shape. + * Empty when a group has no carve-outs (dropped by array_filter on the caller). + * + * @param array $overrides + * @return array> + */ + protected function ruleActionOverrides(array $overrides): array + { + return collect($overrides) + ->map(fn (string $action, string $name): array => ['Name' => $name, 'ActionToUse' => [$action => []]]) + ->values() + ->all(); + } + /** * @return array */ diff --git a/tests/Unit/Resources/CloudWatch/DashboardTest.php b/tests/Unit/Resources/CloudWatch/DashboardTest.php index 7a785954..a1090814 100644 --- a/tests/Unit/Resources/CloudWatch/DashboardTest.php +++ b/tests/Unit/Resources/CloudWatch/DashboardTest.php @@ -245,7 +245,7 @@ public function __invoke(CommandInterface $cmd, $request) // [namespace, metric, WebACL, , Region, , Rule, ALL, {options}] expect($metric)->toContain('AWS/WAFV2', 'yolo-testing-waf', 'ap-southeast-2', 'ALL'); - $byRule = findWidget($body, 'Blocked / counted by rule'); + $byRule = findWidget($body, 'Blocked by rule'); expect(collect($byRule['properties']['metrics'])->pluck(7)) ->toContain('yolo-block-ips', 'yolo-rate-limit', 'AWS-AWSManagedRulesCommonRuleSet'); }); diff --git a/tests/Unit/Resources/WafV2/WebAclTest.php b/tests/Unit/Resources/WafV2/WebAclTest.php index 81bbb768..62dcab66 100644 --- a/tests/Unit/Resources/WafV2/WebAclTest.php +++ b/tests/Unit/Resources/WafV2/WebAclTest.php @@ -41,14 +41,21 @@ 'yolo-rate-limit', ]); - // Only the broad CRS ships in Count; the targeted groups block outright. + // Every managed group blocks (override None). $byName = collect($create['args']['Rules'])->keyBy('Name'); - expect($byName['AWS-AWSManagedRulesCommonRuleSet']['OverrideAction'])->toBe(['Count' => []]) + expect($byName['AWS-AWSManagedRulesCommonRuleSet']['OverrideAction'])->toBe(['None' => []]) ->and($byName['AWS-AWSManagedRulesSQLiRuleSet']['OverrideAction'])->toBe(['None' => []]) ->and($byName['AWS-AWSManagedRulesPHPRuleSet']['OverrideAction'])->toBe(['None' => []]) ->and($byName['AWS-AWSManagedRulesAmazonIpReputationList']['OverrideAction'])->toBe(['None' => []]) ->and($byName['AWS-AWSManagedRulesKnownBadInputsRuleSet']['OverrideAction'])->toBe(['None' => []]); + // CRS blocks, but its 8 KB body-size sub-rule is carved out to Count so large + // legit POSTs aren't blocked; the other groups carry no overrides. + expect($byName['AWS-AWSManagedRulesCommonRuleSet']['Statement']['ManagedRuleGroupStatement']['RuleActionOverrides']) + ->toBe([['Name' => 'SizeRestrictions_BODY', 'ActionToUse' => ['Count' => []]]]); + expect($byName['AWS-AWSManagedRulesSQLiRuleSet']['Statement']['ManagedRuleGroupStatement']) + ->not->toHaveKey('RuleActionOverrides'); + // Managed groups are referenced unversioned so they track the latest signatures. expect($byName['AWS-AWSManagedRulesCommonRuleSet']['Statement']['ManagedRuleGroupStatement']) ->not->toHaveKey('Version'); From 1983880da619593e3bd2c66560d72ca36e6deaf4 Mon Sep 17 00:00:00 2001 From: stevethomas Date: Tue, 9 Jun 2026 17:33:09 +1000 Subject: [PATCH 11/11] test(waf): add the canonical reconciler-contract test to the IP set step The WebAcl and association steps already carry assertSyncStepReconciles; the IP set steps had bespoke create/create-only tests. Add the shared reconciler contract (tag drift is their only reconcile axis, since contents are create-only) so every WAF sync step matches the codebase pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/Unit/Resources/WafV2/IpSetTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Unit/Resources/WafV2/IpSetTest.php b/tests/Unit/Resources/WafV2/IpSetTest.php index b5e83d30..101ca3b9 100644 --- a/tests/Unit/Resources/WafV2/IpSetTest.php +++ b/tests/Unit/Resources/WafV2/IpSetTest.php @@ -86,3 +86,27 @@ function allowIpSetTagsResult(): Result ->not->toContain('UpdateIPSet') ->not->toContain('CreateIPSet'); }); + +it('honours the reconciler contract (tag drift) for the IP set step', function (): void { + // Contents are create-only, so the IP set's only reconcile axis is its tags: + // in-sync ⇒ SYNCED, no write; a missing tag ⇒ WOULD_SYNC on the plan, TagResource on apply. + assertSyncStepReconciles( + makeStep: fn (): SyncWafAllowIpSetStep => new SyncWafAllowIpSetStep(), + bindInSync: function (array &$captured): void { + bindRoutedWafV2Client([ + 'ListIPSets' => allowIpSetListResult(), + 'ListTagsForResource' => allowIpSetTagsResult(), + ], $captured); + }, + bindDrifted: function (array &$captured): void { + bindRoutedWafV2Client([ + 'ListIPSets' => allowIpSetListResult(), + 'ListTagsForResource' => new Result(['TagInfoForResource' => ['TagList' => [ + ['Key' => 'Name', 'Value' => 'yolo-testing-waf-allow'], + ]]]), + 'TagResource' => new Result([]), + ], $captured); + }, + writeCommand: 'TagResource', + ); +});