diff --git a/docs/guide/provisioning.md b/docs/guide/provisioning.md index 56aade8e..c6bf7092 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,18 @@ 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 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** — 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**: + +- 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. + +Tune the rest in the AWS console; YOLO won't undo it. + ## 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 e51f1522..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, 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 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 019bb221..60af0ace 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -241,6 +241,10 @@ ivs: MediaConvert role ARN for video transcoding workloads (used with IVS). +::: 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` 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/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/Resources/CloudWatch/Dashboard.php b/src/Resources/CloudWatch/Dashboard.php index 347dffdf..d6f92b19 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,66 @@ 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 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} + */ + 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 — every group blocks, so each is + // charted as BlockedRequests showing where blocks originate. + $widgets[] = static::metric(12, $y, 12, 6, [ + 'title' => 'Blocked 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', '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]), + ], + ]); + $y += 6; + + return [$widgets, $y]; + } + protected static function queueSection(array $context, int $y): array { $region = $context['region']; 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..285102c7 --- /dev/null +++ b/src/Resources/WafV2/WebAcl.php @@ -0,0 +1,432 @@ + + */ + private const array BANNED_COUNTRIES = [ + 'CN', 'GH', 'KP', 'LB', 'NG', 'RU', 'BD', 'NP', 'IQ', 'IR', 'CI', + ]; + + private const string ALLOW_RULE = 'yolo-allow-ips'; + + private const string BLOCK_RULE = 'yolo-block-ips'; + + private const string RATE_RULE = 'yolo-rate-limit'; + + private const string COUNTRY_RULE = 'yolo-banned-countries'; + + public function name(): string + { + return $this->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->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); + } + + /** + * 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. 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], + ['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' => ['None' => []], + 'Statement' => [ + '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 + */ + 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', + '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. + * + * @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..3f2d6d36 --- /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..2bdde46a --- /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..d25f88b5 --- /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..51b4a433 --- /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..c434997a 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; @@ -13,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; @@ -513,3 +515,100 @@ 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, + ])); +} + +/** + * 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 WebAcl())->desiredRules(); +} 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/Resources/CloudWatch/DashboardTest.php b/tests/Unit/Resources/CloudWatch/DashboardTest.php index 8025004a..a1090814 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 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"); diff --git a/tests/Unit/Resources/WafV2/IpSetTest.php b/tests/Unit/Resources/WafV2/IpSetTest.php new file mode 100644 index 00000000..101ca3b9 --- /dev/null +++ b/tests/Unit/Resources/WafV2/IpSetTest.php @@ -0,0 +1,112 @@ + '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'); +}); + +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', + ); +}); diff --git a/tests/Unit/Resources/WafV2/WebAclTest.php b/tests/Unit/Resources/WafV2/WebAclTest.php new file mode 100644 index 00000000..62dcab66 --- /dev/null +++ b/tests/Unit/Resources/WafV2/WebAclTest.php @@ -0,0 +1,197 @@ + '111111111111', 'region' => 'ap-southeast-2']); +}); + +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'); + + // 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', + 'AWS-AWSManagedRulesSQLiRuleSet', + 'AWS-AWSManagedRulesPHPRuleSet', + 'yolo-rate-limit', + ]); + + // Every managed group blocks (override None). + $byName = collect($create['args']['Rules'])->keyBy('Name'); + 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'); + + // 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); + 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..a50f3641 --- /dev/null +++ b/tests/Unit/Steps/Waf/WafStepsTest.php @@ -0,0 +1,85 @@ + '111111111111', 'region' => 'ap-southeast-2']); +}); + +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'); +});