diff --git a/src/Commands/SyncComputeCommand.php b/src/Commands/SyncComputeCommand.php index 81cf94e..700f358 100644 --- a/src/Commands/SyncComputeCommand.php +++ b/src/Commands/SyncComputeCommand.php @@ -10,6 +10,7 @@ class SyncComputeCommand extends SyncSteppedCommand Steps\Fargate\SyncEcrRepositoryStep::class, Steps\Fargate\SyncEcsClusterStep::class, Steps\Fargate\SyncTaskSecurityGroupStep::class, + Steps\Network\SyncRdsSecurityGroupStep::class, Steps\Fargate\SyncLoadBalancerStep::class, Steps\Fargate\SyncTargetGroupStep::class, Steps\Fargate\SyncHttpListenerStep::class, diff --git a/src/Commands/SyncNetworkCommand.php b/src/Commands/SyncNetworkCommand.php index e5cb521..53a1b77 100644 --- a/src/Commands/SyncNetworkCommand.php +++ b/src/Commands/SyncNetworkCommand.php @@ -27,8 +27,6 @@ class SyncNetworkCommand extends SyncSteppedCommand // security groups Steps\Network\SyncLoadBalancerSecurityGroupStep::class, - Steps\Network\SyncEc2SecurityGroupStep::class, - Steps\Network\SyncRdsSecurityGroupStep::class, // sns Steps\Network\SyncSnsAlarmTopicStep::class, diff --git a/src/Concerns/UsesEc2.php b/src/Concerns/UsesEc2.php index 07b964e..968f0a2 100644 --- a/src/Concerns/UsesEc2.php +++ b/src/Concerns/UsesEc2.php @@ -6,7 +6,6 @@ use Codinglabs\Yolo\Aws; use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; -use Codinglabs\Yolo\Enums\Iam; use Codinglabs\Yolo\AwsResources; use Codinglabs\Yolo\Enums\PublicSubnets; use Codinglabs\Yolo\Enums\SecurityGroup; @@ -18,22 +17,16 @@ trait UsesEc2 protected static array $internetGateway; - protected static array $launchTemplate; - protected static array $subnets; protected static array $routeTable; protected static array $loadBalancerSecurityGroup; - protected static array $ec2SecurityGroup; - protected static array $ecsTaskSecurityGroup; protected static array $rdsSecurityGroup; - protected static array $keyPair; - public static function availabilityZones(string $region): array { $availabilityZones = Aws::ec2()->describeAvailabilityZones([ @@ -117,20 +110,6 @@ public static function loadBalancerSecurityGroup(): array return static::$loadBalancerSecurityGroup; } - /** - * @throws ResourceDoesNotExistException - */ - public static function ec2SecurityGroup(): array - { - if (isset(static::$ec2SecurityGroup)) { - return static::$ec2SecurityGroup; - } - - static::$ec2SecurityGroup = static::securityGroupByName(Manifest::get('aws.ec2.security-group', SecurityGroup::EC2_SECURITY_GROUP)); - - return static::$ec2SecurityGroup; - } - /** * @throws ResourceDoesNotExistException */ @@ -179,59 +158,6 @@ public static function securityGroupByName(string|BackedEnum $name): array throw new ResourceDoesNotExistException("Could not find Security Group matching name $name"); } - public static function launchTemplate($refresh = false): array - { - if (! $refresh && isset(static::$launchTemplate)) { - return static::$launchTemplate; - } - - $launchTemplates = Aws::ec2()->describeLaunchTemplates([ - 'Filters' => [ - [ - 'Name' => 'launch-template-name', - 'Values' => [Helpers::keyedResourceName(exclusive: false)], - ], - ], - ])['LaunchTemplates']; - - if (count($launchTemplates) === 0) { - throw ResourceDoesNotExistException::make(sprintf('Could not find launch template %s', Helpers::keyedResourceName())) - ->suggest('compute:sync'); - } - - static::$launchTemplate = $launchTemplates[0]; - - return static::$launchTemplate; - } - - public static function launchTemplatePayload(): array - { - return [ - 'LaunchTemplateName' => Helpers::keyedResourceName(exclusive: false), - 'LaunchTemplateData' => [ - 'IamInstanceProfile' => [ - 'Name' => Helpers::keyedResourceName(Iam::INSTANCE_PROFILE, exclusive: false), - ], - 'InstanceType' => Manifest::get('aws.ec2.instance-type'), - 'KeyName' => Manifest::get('aws.ec2.key-pair', Helpers::keyedResourceName(exclusive: false)), - 'SecurityGroupIds' => [ - AwsResources::ec2SecurityGroup()['GroupId'], - ], - 'Monitoring' => [ - 'Enabled' => true, - ], - ], - 'TagSpecifications' => [ - [ - 'ResourceType' => 'launch-template', - ...Aws::tags([ - 'Name' => Helpers::keyedResourceName(), - ]), - ], - ], - ]; - } - public static function vpc(): array { if (isset(static::$vpc)) { @@ -357,24 +283,4 @@ public static function subnetByName(string $name, $relative = true): array throw new ResourceDoesNotExistException("Could not find subnet matching name $fullSubnetName"); } - - public static function keyPair(): array - { - if (isset(static::$keyPair)) { - return static::$keyPair; - } - - $name = Manifest::get('aws.ec2.key-pair', Helpers::keyedResourceName(exclusive: false)); - - foreach (Aws::ec2()->describeKeyPairs()['KeyPairs'] as $keyPair) { - if ($keyPair['KeyName'] === $name) { - static::$keyPair = $keyPair; - - return $keyPair; - } - } - - throw ResourceDoesNotExistException::make("Could not find key pair with name $name") - ->suggest('sync:network'); - } } diff --git a/src/Enums/SecurityGroup.php b/src/Enums/SecurityGroup.php index 1809a65..03632e2 100644 --- a/src/Enums/SecurityGroup.php +++ b/src/Enums/SecurityGroup.php @@ -4,7 +4,6 @@ enum SecurityGroup: string { - case EC2_SECURITY_GROUP = 'ec2-security-group'; case ECS_TASK_SECURITY_GROUP = 'ecs-task-security-group'; case LOAD_BALANCER_SECURITY_GROUP = 'load-balancer-security-group'; case RDS_SECURITY_GROUP = 'rds-security-group'; diff --git a/src/Enums/SecurityGroupRule.php b/src/Enums/SecurityGroupRule.php index 287063c..aa4f0e2 100644 --- a/src/Enums/SecurityGroupRule.php +++ b/src/Enums/SecurityGroupRule.php @@ -6,7 +6,5 @@ enum SecurityGroupRule: string { case LOAD_BALANCER_HTTP_RULE = 'load-balancer-http'; case LOAD_BALANCER_HTTPS_RULE = 'load-balancer-https'; - case LOAD_BALANCER_INGRESS_RULE = 'load-balancer-ingress'; case ECS_TASK_LB_INGRESS_RULE = 'ecs-task-lb-ingress'; - case SSH_INGRESS_RULE = 'ssh-ingress'; } diff --git a/src/Steps/Network/SyncEc2SecurityGroupStep.php b/src/Steps/Network/SyncEc2SecurityGroupStep.php deleted file mode 100644 index 907ba13..0000000 --- a/src/Steps/Network/SyncEc2SecurityGroupStep.php +++ /dev/null @@ -1,208 +0,0 @@ -value => fn (array $securityGroup) => static::loadBalanceIngressRule($securityGroup), - SecurityGroupRule::SSH_INGRESS_RULE->value => fn (array $securityGroup) => static::sshIngressRule($securityGroup), - ]; - - try { - $securityGroup = AwsResources::ec2SecurityGroup(); - - if (Manifest::has('aws.ec2.security-group')) { - return StepResult::CUSTOM_MANAGED; - } - - foreach ($expectedRules as $tag => $expectedRule) { - $securityGroupRules = Aws::ec2()->describeSecurityGroupRules([ - 'Filters' => [ - [ - 'Name' => 'group-id', - 'Values' => [$securityGroup['GroupId']], - ], - [ - 'Name' => 'tag:yolo:rule-type', - 'Values' => [$tag], - ], - ], - ])['SecurityGroupRules']; - - if (empty($securityGroupRules)) { - // if a rule is missing, add it - if (! Arr::get($options, 'dry-run')) { - Aws::ec2()->authorizeSecurityGroupIngress($expectedRule($securityGroup)); - } else { - return StepResult::OUT_OF_SYNC; - } - } elseif (static::rulesAreDifferent($expectedRule($securityGroup)['IpPermissions'][0], $securityGroupRules[0])) { - if (! Arr::get($options, 'dry-run')) { - $payload = $expectedRule($securityGroup)['IpPermissions'][0]; - - if (isset($payload['UserIdGroupPairs'])) { - $payload['ReferencedGroupId'] = $payload['UserIdGroupPairs'][0]['GroupId']; - $payload['Description'] = $payload['UserIdGroupPairs'][0]['Description']; - - unset($payload['UserIdGroupPairs']); - } - - if (isset($payload['IpRanges'])) { - $payload['CidrIpv4'] = $payload['IpRanges'][0]['CidrIp']; - $payload['Description'] = $payload['IpRanges'][0]['Description']; - - unset($payload['IpRanges']); - } - - Aws::ec2()->modifySecurityGroupRules([ - 'GroupId' => $securityGroup['GroupId'], - 'SecurityGroupRules' => [ - [ - 'SecurityGroupRule' => $payload, - 'SecurityGroupRuleId' => $securityGroupRules[0]['SecurityGroupRuleId'], - ], - ], - ]); - } else { - return StepResult::OUT_OF_SYNC; - } - } - } - - return StepResult::SYNCED; - } catch (ResourceDoesNotExistException) { - if (! Arr::get($options, 'dry-run')) { - if (Manifest::get('aws.ec2.security-group')) { - throw IntegrityCheckException::make('yolo.yml specifies a custom EC2 security group which does not exist'); - } - - $name = Helpers::keyedResourceName(SecurityGroup::EC2_SECURITY_GROUP, exclusive: false); - - Aws::ec2()->createSecurityGroup([ - 'Description' => 'Enable load balancer and SSH traffic', - 'GroupName' => $name, - 'VpcId' => AwsResources::vpc()['VpcId'], - 'TagSpecifications' => [ - [ - 'ResourceType' => 'security-group', - ...Aws::tags([ - 'Name' => $name, - ]), - ], - ], - ]); - - $securityGroup = AwsResources::ec2SecurityGroup(); - - foreach ($expectedRules as $expectedRule) { - Aws::ec2()->authorizeSecurityGroupIngress($expectedRule($securityGroup)); - } - - return StepResult::CREATED; - } - - return Manifest::get('aws.ec2.security-group') - ? StepResult::MANIFEST_INVALID - : StepResult::WOULD_CREATE; - } - } - - protected static function loadBalanceIngressRule(array $securityGroup): array - { - return [ - 'GroupId' => $securityGroup['GroupId'], - 'IpPermissions' => [ - [ - 'IpProtocol' => 'tcp', - 'FromPort' => 80, - 'ToPort' => 80, - 'UserIdGroupPairs' => [ - [ - 'GroupId' => AwsResources::loadBalancerSecurityGroup()['GroupId'], - 'Description' => 'HTTP ingress from the load balancer', - ], - ], - ], - ], - 'TagSpecifications' => [ - [ - 'ResourceType' => 'security-group-rule', - 'Tags' => [ - [ - 'Key' => 'yolo:rule-type', - 'Value' => SecurityGroupRule::LOAD_BALANCER_INGRESS_RULE->value, - ], - ], - ], - ], - ]; - } - - protected static function sshIngressRule(array $securityGroup): array - { - $publicIp = file_get_contents('https://api.ipify.org'); - - return [ - 'GroupId' => $securityGroup['GroupId'], - 'IpPermissions' => [ - [ - 'IpProtocol' => 'tcp', - 'FromPort' => 22, - 'ToPort' => 22, - 'IpRanges' => [ - [ - 'CidrIp' => "$publicIp/32", - 'Description' => 'YOLO-determined public IP during sync. Delete if unused.', - ], - ], - ], - ], - 'TagSpecifications' => [ - [ - 'ResourceType' => 'security-group-rule', - 'Tags' => [ - [ - 'Key' => 'yolo:rule-type', - 'Value' => SecurityGroupRule::SSH_INGRESS_RULE->value, - ], - ], - ], - ], - ]; - } - - protected static function rulesAreDifferent(array $expectedRule, array $rule): bool - { - if (isset($expectedRule['UserIdGroupPairs'])) { - // map to ReferencedGroupInfo - $expectedRule['ReferencedGroupInfo'] = [ - 'GroupId' => $expectedRule['UserIdGroupPairs'][0]['GroupId'], - 'UserId' => $rule['GroupOwnerId'], - ]; - unset($expectedRule['UserIdGroupPairs']); - } - - if (isset($expectedRule['IpRanges'])) { - // map existing IP to expected IP - $expectedRule['CidrIpv4'] = $rule['CidrIpv4']; - unset($expectedRule['IpRanges']); - } - - return Helpers::payloadHasDifferences($expectedRule, $rule); - } -} diff --git a/src/Steps/Network/SyncRdsSecurityGroupStep.php b/src/Steps/Network/SyncRdsSecurityGroupStep.php index ab5c99c..7225c97 100644 --- a/src/Steps/Network/SyncRdsSecurityGroupStep.php +++ b/src/Steps/Network/SyncRdsSecurityGroupStep.php @@ -4,32 +4,47 @@ use Codinglabs\Yolo\Aws; use Illuminate\Support\Arr; +use Codinglabs\Yolo\Aws\Ec2; use Codinglabs\Yolo\Helpers; use Codinglabs\Yolo\Manifest; use Codinglabs\Yolo\AwsResources; use Codinglabs\Yolo\Contracts\Step; use Codinglabs\Yolo\Enums\StepResult; use Codinglabs\Yolo\Enums\SecurityGroup; +use Codinglabs\Yolo\Resources\Fargate\EcsTaskSecurityGroup; use Codinglabs\Yolo\Exceptions\ResourceDoesNotExistException; +/** + * Provisions the RDS security group and authorises the Fargate tasks to reach + * the database on 3306. Runs in sync:compute (after SyncTaskSecurityGroupStep) + * rather than sync:network, because the ingress source is the ECS task SG, which + * sync:compute creates — the RDS subnet group stays in sync:network. + * + * The ingress rule is managed purely additively: we ensure a "3306 from the task + * SG" rule exists and never revoke anything. Any rule added out of band (e.g. a + * legacy EC2 SG, a bastion, a hand-granted CIDR) is left untouched, so this can't + * sever existing database access. + */ class SyncRdsSecurityGroupStep implements Step { public function __invoke(array $options): StepResult { try { - AwsResources::rdsSecurityGroup(); + $securityGroup = AwsResources::rdsSecurityGroup(); if (Manifest::has('aws.rds.security-group')) { return StepResult::CUSTOM_MANAGED; } + $this->ensureTaskIngressRule($securityGroup['GroupId'], (bool) Arr::get($options, 'dry-run')); + return StepResult::SYNCED; } catch (ResourceDoesNotExistException) { if (! Arr::get($options, 'dry-run')) { $name = Helpers::keyedResourceName(SecurityGroup::RDS_SECURITY_GROUP, exclusive: false); Aws::ec2()->createSecurityGroup([ - 'Description' => 'Enable EC2 to connect to RDS', + 'Description' => 'Enable Fargate tasks to connect to RDS', 'GroupName' => $name, 'VpcId' => AwsResources::vpc()['VpcId'], 'TagSpecifications' => [ @@ -42,25 +57,7 @@ public function __invoke(array $options): StepResult ], ]); - $securityGroup = AwsResources::rdsSecurityGroup(); - - Aws::ec2()->authorizeSecurityGroupIngress([ - 'GroupId' => $securityGroup['GroupId'], - 'IpPermissions' => [ - [ - // Enable EC2 to connect to RDS - 'IpProtocol' => 'tcp', - 'FromPort' => 3306, - 'ToPort' => 3306, - 'UserIdGroupPairs' => [ - [ - 'GroupId' => AwsResources::ec2SecurityGroup()['GroupId'], - 'Description' => 'Enable EC2 to connect to RDS', - ], - ], - ], - ], - ]); + $this->ensureTaskIngressRule(AwsResources::rdsSecurityGroup()['GroupId'], false); return StepResult::CREATED; } @@ -68,4 +65,48 @@ public function __invoke(array $options): StepResult return StepResult::WOULD_CREATE; } } + + /** + * Additively ensure a 3306-from-task-SG ingress rule exists, identified by its + * content (AWS rejects duplicate permissions anyway, so no marker tag is + * needed). Never revokes, and a no-op under --dry-run. + */ + protected function ensureTaskIngressRule(string $groupId, bool $dryRun): void + { + if ($dryRun) { + return; + } + + // Name lookup throws ResourceDoesNotExistException if the task SG is + // missing — sync:compute provisions it before this step runs. + $taskSecurityGroupId = (new EcsTaskSecurityGroup())->arn(); + + $alreadyAuthorised = collect(Ec2::securityGroupRules($groupId))->contains( + fn (array $rule) => ! ($rule['IsEgress'] ?? false) + && ($rule['IpProtocol'] ?? null) === 'tcp' + && ($rule['FromPort'] ?? null) === 3306 + && ($rule['ReferencedGroupInfo']['GroupId'] ?? null) === $taskSecurityGroupId + ); + + if ($alreadyAuthorised) { + return; + } + + Aws::ec2()->authorizeSecurityGroupIngress([ + 'GroupId' => $groupId, + 'IpPermissions' => [ + [ + 'IpProtocol' => 'tcp', + 'FromPort' => 3306, + 'ToPort' => 3306, + 'UserIdGroupPairs' => [ + [ + 'GroupId' => $taskSecurityGroupId, + 'Description' => 'Enable Fargate tasks to connect to RDS', + ], + ], + ], + ], + ]); + } } diff --git a/src/Steps/Storage/SyncS3ArtefactBucketStep.php b/src/Steps/Storage/SyncS3ArtefactBucketStep.php index 595dcba..e7a55ab 100644 --- a/src/Steps/Storage/SyncS3ArtefactBucketStep.php +++ b/src/Steps/Storage/SyncS3ArtefactBucketStep.php @@ -15,13 +15,20 @@ class SyncS3ArtefactBucketStep implements Step public function __invoke(array $options): StepResult { $bucketName = Paths::s3ArtefactsBucket(); + $dryRun = (bool) Arr::get($options, 'dry-run'); try { AwsResources::bucket($bucketName); + // Reconcile the hardening onto the existing bucket — safe to re-apply + // (it's never public and tiny), so older buckets pick it up on sync. + if (! $dryRun) { + $this->harden($bucketName); + } + return StepResult::SYNCED; } catch (ResourceDoesNotExistException $e) { - if (! Arr::get($options, 'dry-run')) { + if (! $dryRun) { Aws::s3()->createBucket([ 'Bucket' => $bucketName, ]); @@ -39,6 +46,8 @@ public function __invoke(array $options): StepResult ], ]); + $this->harden($bucketName); + // todo: this requires the ELB account ID, which is not the same as the account ID. // todo: @see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/enable-access-logging.html // Aws::s3()->putBucketPolicy([ @@ -62,4 +71,29 @@ public function __invoke(array $options): StepResult return StepResult::WOULD_CREATE; } } + + /** + * The artefact bucket holds the application's `.env` files, so it must never + * be publicly reachable and its objects should be recoverable. Lock down all + * four Block Public Access settings and enable versioning (recovery + + * tamper-evidence for a clobbered or malicious `.env`). Both are declarative + * puts — idempotent, safe to re-apply on every sync. + */ + protected function harden(string $bucketName): void + { + Aws::s3()->putPublicAccessBlock([ + 'Bucket' => $bucketName, + 'PublicAccessBlockConfiguration' => [ + 'BlockPublicAcls' => true, + 'IgnorePublicAcls' => true, + 'BlockPublicPolicy' => true, + 'RestrictPublicBuckets' => true, + ], + ]); + + Aws::s3()->putBucketVersioning([ + 'Bucket' => $bucketName, + 'VersioningConfiguration' => ['Status' => 'Enabled'], + ]); + } } diff --git a/src/Steps/Storage/SyncS3BucketStep.php b/src/Steps/Storage/SyncS3BucketStep.php index 11447d5..ac9a1f5 100644 --- a/src/Steps/Storage/SyncS3BucketStep.php +++ b/src/Steps/Storage/SyncS3BucketStep.php @@ -38,6 +38,19 @@ public function __invoke(array $options): StepResult 'Bucket' => $bucketName, ]); + // Secure-by-default for new app buckets. Deliberately NOT reconciled + // onto existing buckets — an app may already serve public objects, and + // flipping Block Public Access under it would break that. + Aws::s3()->putPublicAccessBlock([ + 'Bucket' => $bucketName, + 'PublicAccessBlockConfiguration' => [ + 'BlockPublicAcls' => true, + 'IgnorePublicAcls' => true, + 'BlockPublicPolicy' => true, + 'RestrictPublicBuckets' => true, + ], + ]); + Aws::s3()->putBucketTagging([ 'Bucket' => $bucketName, 'Tagging' => [ diff --git a/tests/Unit/Steps/Network/SyncRdsSecurityGroupStepTest.php b/tests/Unit/Steps/Network/SyncRdsSecurityGroupStepTest.php new file mode 100644 index 0000000..1bdb376 --- /dev/null +++ b/tests/Unit/Steps/Network/SyncRdsSecurityGroupStepTest.php @@ -0,0 +1,174 @@ +> $byCommand + * @param array}> $captured + */ +function bindMockEc2Client(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('ec2', new Ec2Client([ + 'region' => 'ap-southeast-2', + 'version' => 'latest', + 'credentials' => false, + 'handler' => $mock, + ])); +} + +function describeRdsAndTaskGroups(): Result +{ + return new Result([ + 'SecurityGroups' => [ + ['GroupName' => 'yolo-testing-rds-security-group', 'GroupId' => 'sg-rds123'], + ['GroupName' => 'yolo-testing-my-app-ecs-task-security-group', 'GroupId' => 'sg-task456'], + ], + ]); +} + +beforeEach(function () { + writeManifest([ + 'aws' => ['account-id' => '111111111111', 'region' => 'ap-southeast-2'], + ]); +}); + +// This test must run first: it relies on AwsResources::rdsSecurityGroup() not yet +// being memoised, so the absent-SG lookup throws and the create branch runs. +it('creates the RDS security group and adds the task-SG ingress rule when absent', function () { + $captured = []; + + bindMockEc2Client([ + 'DescribeSecurityGroups' => [ + new Result(['SecurityGroups' => []]), // first lookup → not found → create + describeRdsAndTaskGroups(), // re-lookup after create (repeats) + ], + 'DescribeVpcs' => new Result(['Vpcs' => [['VpcId' => 'vpc-1']]]), + 'CreateSecurityGroup' => new Result(['GroupId' => 'sg-rds123']), + 'DescribeSecurityGroupRules' => new Result(['SecurityGroupRules' => []]), + 'AuthorizeSecurityGroupIngress' => new Result(), + ], $captured); + + expect((new SyncRdsSecurityGroupStep())([]))->toBe(StepResult::CREATED); + + $names = array_column($captured, 'name'); + expect($names)->toContain('CreateSecurityGroup'); + expect($names)->toContain('AuthorizeSecurityGroupIngress'); + expect($names)->not->toContain('RevokeSecurityGroupIngress'); +}); + +it('additively authorises 3306 from the task security group on an existing RDS SG', function () { + $captured = []; + + bindMockEc2Client([ + 'DescribeSecurityGroups' => describeRdsAndTaskGroups(), + 'DescribeSecurityGroupRules' => new Result(['SecurityGroupRules' => []]), + 'AuthorizeSecurityGroupIngress' => new Result(), + ], $captured); + + expect((new SyncRdsSecurityGroupStep())([]))->toBe(StepResult::SYNCED); + + $authorise = collect($captured)->firstWhere('name', 'AuthorizeSecurityGroupIngress'); + expect($authorise)->not->toBeNull(); + + $permission = $authorise['args']['IpPermissions'][0]; + expect($permission['FromPort'])->toBe(3306); + expect($permission['ToPort'])->toBe(3306); + expect($permission['UserIdGroupPairs'][0]['GroupId'])->toBe('sg-task456'); + expect($authorise['args']['GroupId'])->toBe('sg-rds123'); + + // Purely additive — it must never revoke an existing rule. + expect(array_column($captured, 'name'))->not->toContain('RevokeSecurityGroupIngress'); +}); + +it('does not authorise again when a matching task-SG rule already exists', function () { + $captured = []; + + bindMockEc2Client([ + 'DescribeSecurityGroups' => describeRdsAndTaskGroups(), + // An existing 3306-from-task-SG rule — note it carries no marker tag, so + // matching by content (not a tag) is what lets us spot it. + 'DescribeSecurityGroupRules' => new Result(['SecurityGroupRules' => [ + [ + 'SecurityGroupRuleId' => 'sgr-existing', + 'IsEgress' => false, + 'IpProtocol' => 'tcp', + 'FromPort' => 3306, + 'ToPort' => 3306, + 'ReferencedGroupInfo' => ['GroupId' => 'sg-task456'], + ], + ]]), + ], $captured); + + (new SyncRdsSecurityGroupStep())([]); + + expect(array_column($captured, 'name')) + ->not->toContain('AuthorizeSecurityGroupIngress') + ->not->toContain('RevokeSecurityGroupIngress'); +}); + +it('treats a manifest-specified RDS security group as custom-managed', function () { + writeManifest([ + 'aws' => [ + 'account-id' => '111111111111', + 'region' => 'ap-southeast-2', + 'rds' => ['security-group' => 'yolo-testing-rds-security-group'], + ], + ]); + + $captured = []; + + bindMockEc2Client([ + 'DescribeSecurityGroups' => describeRdsAndTaskGroups(), + ], $captured); + + expect((new SyncRdsSecurityGroupStep())([]))->toBe(StepResult::CUSTOM_MANAGED); + expect(array_column($captured, 'name'))->not->toContain('AuthorizeSecurityGroupIngress'); +}); + +it('does not authorise during a dry-run', function () { + $captured = []; + + bindMockEc2Client([ + 'DescribeSecurityGroups' => describeRdsAndTaskGroups(), + 'DescribeSecurityGroupRules' => new Result(['SecurityGroupRules' => []]), + ], $captured); + + (new SyncRdsSecurityGroupStep())(['dry-run' => true]); + + expect(array_column($captured, 'name'))->not->toContain('AuthorizeSecurityGroupIngress'); +}); diff --git a/tests/Unit/Steps/Storage/SyncS3BucketHardeningTest.php b/tests/Unit/Steps/Storage/SyncS3BucketHardeningTest.php new file mode 100644 index 0000000..8132f97 --- /dev/null +++ b/tests/Unit/Steps/Storage/SyncS3BucketHardeningTest.php @@ -0,0 +1,169 @@ +> $byCommand + * @param array}> $captured + */ +function bindMockS3Client(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 $entry instanceof Throwable + ? Create::rejectionFor($entry) + : Create::promiseFor($entry); + } + }; + + Helpers::app()->instance('s3', new S3Client([ + 'region' => 'ap-southeast-2', + 'version' => 'latest', + 'credentials' => false, + 'handler' => $mock, + ])); +} + +function s3NotFound(): S3Exception +{ + return new S3Exception('Not Found', new Command('HeadBucket'), [ + 'response' => new Response(404), + ]); +} + +beforeEach(function () { + writeManifest([ + 'aws' => ['account-id' => '111111111111', 'region' => 'ap-southeast-2'], + ]); +}); + +it('locks down and versions a newly created artefact bucket', function () { + $captured = []; + + bindMockS3Client([ + // missing on the first check, then present for the BucketExists waiter + // (which matches on a 200 status). + 'HeadBucket' => [s3NotFound(), new Result(['@metadata' => ['statusCode' => 200]])], + 'CreateBucket' => new Result(), + 'PutBucketTagging' => new Result(), + 'PutPublicAccessBlock' => new Result(), + 'PutBucketVersioning' => new Result(), + ], $captured); + + expect((new SyncS3ArtefactBucketStep())([]))->toBe(StepResult::CREATED); + + $names = array_column($captured, 'name'); + expect($names)->toContain('CreateBucket') + ->toContain('PutPublicAccessBlock') + ->toContain('PutBucketVersioning'); + + $blockPublicAccess = collect($captured)->firstWhere('name', 'PutPublicAccessBlock'); + expect($blockPublicAccess['args']['PublicAccessBlockConfiguration'])->toBe([ + 'BlockPublicAcls' => true, + 'IgnorePublicAcls' => true, + 'BlockPublicPolicy' => true, + 'RestrictPublicBuckets' => true, + ]); + + $versioning = collect($captured)->firstWhere('name', 'PutBucketVersioning'); + expect($versioning['args']['VersioningConfiguration']['Status'])->toBe('Enabled'); +}); + +it('reconciles BPA and versioning onto an existing artefact bucket', function () { + $captured = []; + + bindMockS3Client([ + 'HeadBucket' => new Result(), // exists + 'PutPublicAccessBlock' => new Result(), + 'PutBucketVersioning' => new Result(), + ], $captured); + + expect((new SyncS3ArtefactBucketStep())([]))->toBe(StepResult::SYNCED); + + $names = array_column($captured, 'name'); + expect($names)->toContain('PutPublicAccessBlock') + ->toContain('PutBucketVersioning') + ->not->toContain('CreateBucket'); +}); + +it('does not mutate the artefact bucket during a dry-run', function () { + $captured = []; + + bindMockS3Client([ + 'HeadBucket' => new Result(), // exists + ], $captured); + + expect((new SyncS3ArtefactBucketStep())(['dry-run' => true]))->toBe(StepResult::SYNCED); + + $names = array_column($captured, 'name'); + expect($names)->not->toContain('PutPublicAccessBlock') + ->not->toContain('PutBucketVersioning'); +}); + +it('blocks public access on a newly created app bucket', function () { + writeManifest([ + 'aws' => ['account-id' => '111111111111', 'region' => 'ap-southeast-2', 'bucket' => 'my-app-bucket'], + ]); + + $captured = []; + + bindMockS3Client([ + 'HeadBucket' => [s3NotFound(), new Result(['@metadata' => ['statusCode' => 200]])], + 'CreateBucket' => new Result(), + 'PutBucketTagging' => new Result(), + 'PutPublicAccessBlock' => new Result(), + ], $captured); + + expect((new SyncS3BucketStep())([]))->toBe(StepResult::CREATED); + expect(array_column($captured, 'name'))->toContain('PutPublicAccessBlock'); +}); + +it('does not flip public access on an existing app bucket', function () { + writeManifest([ + 'aws' => ['account-id' => '111111111111', 'region' => 'ap-southeast-2', 'bucket' => 'my-app-bucket'], + ]); + + $captured = []; + + bindMockS3Client([ + 'HeadBucket' => new Result(), // exists + ], $captured); + + expect((new SyncS3BucketStep())([]))->toBe(StepResult::SYNCED); + expect(array_column($captured, 'name'))->not->toContain('PutPublicAccessBlock'); +});