mirror of
https://github.com/Sea-Haven-Industries/seahaven-account-baseline.git
synced 2026-08-04 16:56:14 +00:00
Compare commits
14 commits
a0f4fa84a4
...
1d29183743
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d29183743 | ||
|
|
34da6b4082 | ||
|
|
a9d9f12a40 | ||
|
|
05b0f95c4f | ||
|
|
993702f421 | ||
|
|
2289dcb0c9 | ||
|
|
14593440cf | ||
|
|
0dd8d2a7af | ||
|
|
1d6668090c | ||
|
|
f2a0cc40d6 | ||
|
|
60e8b0e9ed | ||
|
|
3ba90ddc40 | ||
|
|
38d4a5753a | ||
|
|
64ef25dc5b |
19 changed files with 1739 additions and 37 deletions
3
.github/dependabot.yml
vendored
3
.github/dependabot.yml
vendored
|
|
@ -9,9 +9,6 @@ updates:
|
|||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
ignore:
|
||||
# aws-cdk-lib is pinned exactly (bundled transitive deps); bump deliberately.
|
||||
- dependency-name: "aws-cdk-lib"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
|
|
|
|||
6
.github/workflows/dependency-review.yml
vendored
Normal file
6
.github/workflows/dependency-review.yml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
name: Dependency Review
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
review:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@main
|
||||
218
README.md
218
README.md
|
|
@ -1,10 +1,19 @@
|
|||
# seahaven-account-baseline
|
||||
|
||||
Account-level security and governance baseline for Sea Haven Industries
|
||||
(AWS account **328440206208**, region **us-east-1**), managed as a single CDK
|
||||
TypeScript app. This is where account-wide detective controls live, so they are
|
||||
(AWS account **328440206208**), managed as a single CDK TypeScript app. Most
|
||||
resources are in **us-east-1**; the offsite backup vault is in **us-west-2**.
|
||||
This is where account-wide detective and recovery controls live, so they are
|
||||
versioned, reviewed, and drift-checked like any other stack.
|
||||
|
||||
Stacks (all deployed by `cdk deploy --all` / the CD workflow):
|
||||
|
||||
| Stack | Region | Purpose |
|
||||
|---|---|---|
|
||||
| `seahaven-account-baseline` | us-east-1 | CloudTrail + future detective controls (C-1) |
|
||||
| `seahaven-backup` | us-east-1 | Primary AWS Backup vault + plan + role (C-7) |
|
||||
| `seahaven-backup-offsite` | us-west-2 | Governance-locked offsite copy vault (C-7) |
|
||||
|
||||
## What it deploys
|
||||
|
||||
### CloudTrail (audit finding C-1)
|
||||
|
|
@ -36,11 +45,180 @@ finding H-1 (metric filters + alarms now have a log group to target).
|
|||
- **RETAIN** on the bucket and KMS key so a stack teardown never destroys the
|
||||
audit trail.
|
||||
|
||||
### AWS Backup (audit finding C-7)
|
||||
|
||||
Phase 1 ("critical data first") of fixing the account's complete lack of AWS
|
||||
Backup. Protects the data stores with no offsite leg today and copies each
|
||||
recovery point cross-region into a governance-locked vault.
|
||||
|
||||
| Resource | Logical ID | Notes |
|
||||
|---|---|---|
|
||||
| Primary vault | `seahaven-primary` (us-east-1) | KMS-CMK encrypted, unlocked (working copy), RETAIN |
|
||||
| Offsite vault | `seahaven-offsite` (us-west-2) | KMS-CMK encrypted, **Vault Lock GOVERNANCE** (min-retention 30d, no cooling-off window), RETAIN |
|
||||
| Backup plan | `seahaven-critical-daily` | Daily 06:00 UTC, delete-after 35d, **cross-region CopyAction → offsite** (retain 90d) |
|
||||
| Service role | `seahaven-backup-service-role` | **Backup-only** (Backup + S3-Backup managed policies); restore perms intentionally deferred |
|
||||
|
||||
**Phase-1 scope** (selected by explicit ARN, not tags, to avoid drifting other
|
||||
stacks): RDS `proposal-system-db`, DynamoDB `PaymentsDashboard`,
|
||||
DynamoDB `purchase-orders`, S3 `accounting.seahaven.com`,
|
||||
`seahaven-payments-csv-328440206208`, `google-workspace-seahavenind.com`.
|
||||
*(RDS `database-1` was originally in this set but was retired 2026-06-03 —
|
||||
audit H-19, idle 0 conn/60d — and removed from the selection; its final
|
||||
encrypted recovery point is retained in `seahaven-offsite` for 7 years.)*
|
||||
|
||||
**Coexists with** existing EBS DLM snapshots and DynamoDB PITR — it supplements
|
||||
them with the missing offsite + immutable leg; it does not replace them.
|
||||
|
||||
**Design decisions:**
|
||||
|
||||
- **Governance lock first, not compliance.** Recovery points can't be silently
|
||||
deleted, but a principal with explicit permission can still intervene while
|
||||
we validate. Graduate to COMPLIANCE (irreversible) later by adding
|
||||
`changeableFor` to the offsite vault lock + redeploy.
|
||||
- **Backup-only role.** Restore policies and `allowRestores` are not granted;
|
||||
restores get a separate audited path once a restore-test process exists.
|
||||
|
||||
**Pre-deploy gates** (must clear before the first scheduled run):
|
||||
|
||||
1. Enable S3 versioning on `seahaven-payments-csv-328440206208` and
|
||||
`google-workspace-seahavenind.com` (`accounting.seahaven.com` already has it,
|
||||
audit C-9), or their jobs fail silently (folds in H-21).
|
||||
2. `database-1` is unencrypted (H-19): smoke-test an on-demand backup + copy of
|
||||
it to us-west-2 first; if the copy fails, encrypt it or drop it from the copy.
|
||||
3. Enable DynamoDB PITR (H-7) on the two tables for between-window recovery.
|
||||
|
||||
### AWS Backup phase 2 (audit Day 4)
|
||||
|
||||
Expands the same `seahaven-critical-daily` plan to every remaining data store, so
|
||||
all of DynamoDB + EBS get the offsite + immutable leg ("offsite for everything").
|
||||
|
||||
| Resource | Logical ID | Notes |
|
||||
|---|---|---|
|
||||
| Phase-2 selection | `Plan/Phase2Resources` (`phase2-offsite-everything`) | Same plan, same `seahaven-backup-service-role`, same daily + cross-region copy rule |
|
||||
|
||||
**Phase-2 scope:** the 15 remaining DynamoDB tables (all except the two phase-1
|
||||
financial tables + the deleted ledgerflow tables) and all 9 in-use EBS volumes,
|
||||
again **by explicit ARN** — tag-based selection was deliberately avoided because
|
||||
the file-share volumes are standalone-managed and the tables are owned by other
|
||||
stacks, so tagging here would drift them.
|
||||
|
||||
**No IAM change:** `AWSBackupServiceRolePolicyForBackup` already grants the
|
||||
DynamoDB/RDS/EBS backup actions, so phase 2 reuses the phase-1 role unchanged
|
||||
(cross-reviewed, no BLOCK).
|
||||
|
||||
**Known tradeoff (→ Jira INFRA-31):** explicit-ARN EBS entries go stale if a
|
||||
volume is replaced (new volume id), silently dropping it from backup. Migrating
|
||||
the EBS portion to tag-based selection (with the tag codified in each owning
|
||||
stack) is the resilient follow-up; scheduled drift detection is the interim
|
||||
backstop.
|
||||
|
||||
**Also enabled outside this stack (audit H-7, via CLI — codify per stack →
|
||||
INFRA-30):** PITR + `DeletionProtectionEnabled` on 12 more DynamoDB tables
|
||||
(account-wide PITR now 19/21).
|
||||
|
||||
### Detective controls + budget (audit Day 1)
|
||||
|
||||
Account-level detective layer, in `lib/detective-controls.ts`, plus the cost
|
||||
budget in `lib/governance-toggles.ts`. **Scope is us-east-1 only** (all workloads
|
||||
live here); multi-region coverage is a follow-up.
|
||||
|
||||
| Resource | Logical ID | Finding | Notes |
|
||||
|---|---|---|---|
|
||||
| Config delivery bucket | `seahaven-config-328440206208` | H-2 | Private (BPA all), SSE-S3, versioned, TLS-only, 365d lifecycle |
|
||||
| Config recorder role | `seahaven-config-recorder-role` | H-2 | `AWS_ConfigRole` + scoped S3 delivery; **IAM cross-reviewed** |
|
||||
| GuardDuty detector | `DetectiveControls/GuardDutyDetector` | H-3 | Findings every 15 min |
|
||||
| Security Hub | `DetectiveControls/SecurityHub` | H-4 | FSBP v1.0.0 + CIS v3.0.0; controls evaluate once Config is recording |
|
||||
| Access Analyzer | `seahaven-account-analyzer` | M-5 | ACCOUNT external-access analyzer (free) |
|
||||
| Monthly budget | `GovernanceToggles/MonthlyCostBudget` (`seahaven-monthly-cost`) | M-10 | $1,200/mo, 80%/100% actual + 100% forecast → adam@seahavenind.com |
|
||||
|
||||
**Config recorder + delivery channel are NOT in CloudFormation.** The L1
|
||||
`AWS::Config::ConfigurationRecorder` is a stabilizing resource that hangs the
|
||||
stack: it never reaches `CREATE_COMPLETE` until recording is active, which needs
|
||||
a delivery channel, which can't be created until the recorder completes — a
|
||||
deadlock (hit on 2026-06-01). The role + delivery bucket stay in IaC (the role
|
||||
is cross-reviewed); the recorder/channel are created via CLI (below), referencing
|
||||
the stack's `ConfigRecorderRoleArn` output and the `seahaven-config-328440206208`
|
||||
bucket.
|
||||
|
||||
### CLI-applied governance toggles (no CloudFormation resource)
|
||||
|
||||
These account toggles have no native CloudFormation resource, so they are applied
|
||||
via CLI and recorded here. Applied 2026-06-01.
|
||||
|
||||
```bash
|
||||
# M-3 EBS encryption-by-default (new volumes; existing 5 plaintext volumes are H-19-adjacent)
|
||||
aws ec2 enable-ebs-encryption-by-default --region us-east-1
|
||||
|
||||
# M-6 Inspector2 (EC2 + Lambda + ECR)
|
||||
aws inspector2 enable --resource-types EC2 LAMBDA ECR --region us-east-1
|
||||
|
||||
# M-7 IAM password policy (CIS 1.8/1.9): >=14 chars, full complexity, no reuse of last 24
|
||||
aws iam update-account-password-policy \
|
||||
--minimum-password-length 14 \
|
||||
--require-symbols --require-numbers \
|
||||
--require-uppercase-characters --require-lowercase-characters \
|
||||
--allow-users-to-change-password --password-reuse-prevention 24
|
||||
|
||||
# M-11 Activate cost-allocation tags (only activates keys already seen on resources)
|
||||
aws ce update-cost-allocation-tags-status --cost-allocation-tags-status \
|
||||
'TagKey=Project,Status=Active' 'TagKey=Owner,Status=Active' 'TagKey=Environment,Status=Active'
|
||||
```
|
||||
|
||||
**L-8 (billing-metrics preference) is OUTSTANDING — console only.** Enabling the
|
||||
CloudWatch `EstimatedCharges` metric in us-east-1 requires turning on *Receive
|
||||
Billing Alerts* under Billing → Billing preferences; there is no public API/CLI.
|
||||
The M-10 budget already provides cost alerting independent of that metric, so
|
||||
this only affects the legacy `AWS-MonthlyBilling` CloudWatch alarm (L-8).
|
||||
|
||||
### Monitoring + logging (audit Day 2)
|
||||
|
||||
| Resource | Logical ID | Finding | Notes |
|
||||
|---|---|---|---|
|
||||
| CIS metric filters + alarms | `CisMonitoring/*` | H-1 | 15 filters (CIS 4.1–4.15) on the CloudTrail log group, each with an alarm → `seahaven-cis-alarms`. ALARM-only actions (no OK). 4.16 = Security Hub (Day 1) |
|
||||
| CIS alarm topic | `seahaven-cis-alarms` | H-1 | SNS, SSE (`alias/aws/sns`), email sub to adam@seahavenind.com |
|
||||
| VPC flow logs | `FlowLogs/FlowLog0..4` | H-14 | ALL traffic on all 5 VPCs → S3 |
|
||||
| Flow-logs bucket | `seahaven-vpc-flow-logs-328440206208` | H-14 | Private, SSE-S3, TLS-only, Glacier @90d / expire @365d; delivery bucket policy cross-reviewed |
|
||||
| SES config set | `seahaven-email-events` | M-13 | Bounce/complaint/reject → CloudWatch metrics for reputation visibility |
|
||||
|
||||
**H-1 log group:** the metric filters attach to the existing CloudTrail
|
||||
CloudWatch Logs group by name (`seahaven-account-baseline-TrailLogGroup4CBE3AF5-…`),
|
||||
imported read-only so the live audit trail is never replaced. Stable unless the
|
||||
Trail is recreated.
|
||||
|
||||
**H-14 bucket policy note:** the flow-logs delivery policy keeps
|
||||
`s3:x-amz-acl=bucket-owner-full-control` and the `arn:aws:logs:…:*` source-ARN
|
||||
wildcard — both are required by AWS's documented flow-logs-to-S3 policy
|
||||
(`flow-logs-s3-permissions.html`). A cross-review suggested dropping them; that
|
||||
was rejected as it would break delivery. `s3:ListBucket` was dropped (not needed).
|
||||
|
||||
**M-13 follow-up:** associate `seahaven-email-events` as the default config set
|
||||
on the live sending identities to capture events from existing senders:
|
||||
|
||||
```bash
|
||||
aws sesv2 put-email-identity-configuration-set-attributes \
|
||||
--email-identity int.seahaven.com --configuration-set-name seahaven-email-events
|
||||
```
|
||||
|
||||
### Log-group retention + alarm wiring (audit L-4, L-5)
|
||||
|
||||
Applied via CLI (auto-created groups spread across stacks; one alarm in another
|
||||
stack). Applied 2026-06-02.
|
||||
|
||||
```bash
|
||||
# L-4 90-day retention on the 13 never-expire log groups (CodeBuild + CDK helpers)
|
||||
for lg in <the 13 groups>; do aws logs put-retention-policy --log-group-name "$lg" --retention-in-days 90; done
|
||||
|
||||
# L-5 wire the actionless forgejo backup-verification alarm to site-alerts
|
||||
aws cloudwatch put-metric-alarm --alarm-name forgejo-backup-verification-errors \
|
||||
--alarm-actions arn:aws:sns:us-east-1:328440206208:site-alerts # (preserve existing alarm config)
|
||||
```
|
||||
|
||||
## Roadmap (same stack)
|
||||
|
||||
Account-level detective controls with no current home, to be added here:
|
||||
AWS Config (H-2), GuardDuty (H-3), Security Hub (H-4), IAM Access Analyzer
|
||||
(M-5), Inspector2 (M-6).
|
||||
Detective layer multi-region expansion (GuardDuty/Config/Security Hub beyond
|
||||
us-east-1). Backup: phase 2 is deployed (see above); remaining is migrating the
|
||||
phase-2 EBS entries to tag-based selection (INFRA-31) and graduating the offsite
|
||||
vault to compliance mode.
|
||||
|
||||
## Deploy
|
||||
|
||||
|
|
@ -59,3 +237,33 @@ aws cloudtrail get-trail-status --name seahaven-org-trail # IsLogging: tr
|
|||
aws cloudtrail describe-trails --trail-name-list seahaven-org-trail
|
||||
aws cloudtrail validate-logs --trail-arn <arn> --start-time <t> # digest integrity
|
||||
```
|
||||
|
||||
AWS Backup (C-7):
|
||||
|
||||
```
|
||||
aws backup list-backup-vaults # seahaven-primary
|
||||
aws backup list-backup-vaults --region us-west-2 # seahaven-offsite
|
||||
aws backup describe-backup-vault --backup-vault-name seahaven-offsite --region us-west-2 # Locked, MinRetentionDays
|
||||
aws backup get-backup-plan --backup-plan-id <id> # daily rule + CopyAction
|
||||
# Smoke test: on-demand backup of one resource, then confirm the cross-region copy lands
|
||||
aws backup start-backup-job --backup-vault-name seahaven-primary \
|
||||
--resource-arn arn:aws:rds:us-east-1:328440206208:db:proposal-system-db \
|
||||
--iam-role-arn arn:aws:iam::328440206208:role/seahaven-backup-service-role
|
||||
aws backup list-copy-jobs --region us-west-2 # copy to offsite present + COMPLETED
|
||||
# Phase-2 selections live on the plan:
|
||||
aws backup list-backup-selections --backup-plan-id <id> --query 'BackupSelectionsList[].SelectionName' # critical-data + phase2-offsite-everything
|
||||
```
|
||||
|
||||
Detective layer + governance (Day 1):
|
||||
|
||||
```
|
||||
aws configservice describe-configuration-recorder-status # recording: true
|
||||
aws guardduty list-detectors # one detector id
|
||||
aws securityhub get-enabled-standards # FSBP + CIS v3.0.0
|
||||
aws accessanalyzer list-analyzers # seahaven-account-analyzer ACTIVE
|
||||
aws inspector2 batch-get-account-status --region us-east-1 # ec2/ecr/lambda ENABLED
|
||||
aws iam get-account-password-policy # length 14, reuse 24
|
||||
aws ec2 get-ebs-encryption-by-default --region us-east-1 # EbsEncryptionByDefault: true
|
||||
aws budgets describe-budgets --account-id 328440206208 # seahaven-monthly-cost $1,200
|
||||
aws ce list-cost-allocation-tags --status Active # Project/Owner/Environment Active
|
||||
```
|
||||
|
|
|
|||
18
bin/app.ts
18
bin/app.ts
|
|
@ -2,10 +2,28 @@
|
|||
import "source-map-support/register";
|
||||
import * as cdk from "aws-cdk-lib";
|
||||
import { AccountBaselineStack } from "../lib/account-baseline-stack";
|
||||
import { BackupOffsiteStack } from "../lib/backup-offsite-stack";
|
||||
import { BackupStack } from "../lib/backup-stack";
|
||||
|
||||
const app = new cdk.App();
|
||||
|
||||
new AccountBaselineStack(app, "account-baseline", {
|
||||
stackName: "seahaven-account-baseline",
|
||||
env: { account: "328440206208", region: "us-east-1" },
|
||||
monthlyBudgetUsd: 1200,
|
||||
budgetAlertEmail: "adam@seahavenind.com",
|
||||
});
|
||||
|
||||
// AWS Backup (audit C-7). Offsite vault (us-west-2) must exist before the
|
||||
// primary plan that copies to it, hence the explicit dependency.
|
||||
const backupOffsite = new BackupOffsiteStack(app, "backup-offsite", {
|
||||
stackName: "seahaven-backup-offsite",
|
||||
env: { account: "328440206208", region: "us-west-2" },
|
||||
});
|
||||
|
||||
const backupPrimary = new BackupStack(app, "backup", {
|
||||
stackName: "seahaven-backup",
|
||||
env: { account: "328440206208", region: "us-east-1" },
|
||||
});
|
||||
|
||||
backupPrimary.addDependency(backupOffsite);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ import * as iam from "aws-cdk-lib/aws-iam";
|
|||
import * as logs from "aws-cdk-lib/aws-logs";
|
||||
import * as cloudtrail from "aws-cdk-lib/aws-cloudtrail";
|
||||
import { Construct } from "constructs";
|
||||
import { DetectiveControls } from "./detective-controls";
|
||||
import { GovernanceToggles } from "./governance-toggles";
|
||||
import { BedrockLogging } from "./bedrock-logging";
|
||||
import { CisMonitoring } from "./cis-monitoring";
|
||||
import { FlowLogs } from "./flow-logs";
|
||||
import { SesMonitoring } from "./ses-monitoring";
|
||||
import { AppWebAcl } from "./web-acl";
|
||||
|
||||
/**
|
||||
* Account-level security baseline for Sea Haven (account 328440206208).
|
||||
|
|
@ -18,8 +25,15 @@ import { Construct } from "constructs";
|
|||
* Future residents (same stack): AWS Config (H-2), GuardDuty (H-3),
|
||||
* Security Hub (H-4), IAM Access Analyzer (M-5), Inspector2 (M-6).
|
||||
*/
|
||||
export interface AccountBaselineStackProps extends cdk.StackProps {
|
||||
/** Monthly cost budget ceiling in USD (M-10). */
|
||||
readonly monthlyBudgetUsd: number;
|
||||
/** Email for budget threshold alerts (M-10). */
|
||||
readonly budgetAlertEmail: string;
|
||||
}
|
||||
|
||||
export class AccountBaselineStack extends cdk.Stack {
|
||||
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
|
||||
constructor(scope: Construct, id: string, props: AccountBaselineStackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
const trailName = "seahaven-org-trail";
|
||||
|
|
@ -125,6 +139,32 @@ export class AccountBaselineStack extends cdk.Stack {
|
|||
managementEvents: cloudtrail.ReadWriteType.ALL,
|
||||
});
|
||||
|
||||
// ── Day 1 detective layer + governance toggles ──
|
||||
// Config (H-2), GuardDuty (H-3), Security Hub (H-4), Access Analyzer (M-5).
|
||||
new DetectiveControls(this, "DetectiveControls");
|
||||
// Monthly cost budget (M-10). Other governance toggles are CLI + documented.
|
||||
new GovernanceToggles(this, "GovernanceToggles", {
|
||||
monthlyLimitUsd: props.monthlyBudgetUsd,
|
||||
alertEmail: props.budgetAlertEmail,
|
||||
});
|
||||
|
||||
// ── Day 2 monitoring + logging ──
|
||||
// CIS Section 4 metric filters/alarms (H-1), VPC flow logs (H-14),
|
||||
// SES bounce/complaint config set (M-13).
|
||||
new CisMonitoring(this, "CisMonitoring", {
|
||||
alarmEmail: props.budgetAlertEmail,
|
||||
});
|
||||
new FlowLogs(this, "FlowLogs");
|
||||
new SesMonitoring(this, "SesMonitoring");
|
||||
// Shared CloudFront WAF WebACL (M-17); ARN published to SSM for app stacks.
|
||||
new AppWebAcl(this, "AppWebAcl");
|
||||
|
||||
// ── Day 5 AI governance ──
|
||||
// Bedrock model invocation logging destinations + delivery role (H-20).
|
||||
// The account-level logging configuration itself has no CFN resource type;
|
||||
// applied via CLI post-deploy (see lib/bedrock-logging.ts header).
|
||||
new BedrockLogging(this, "BedrockLogging");
|
||||
|
||||
cdk.Tags.of(this).add("Project", "account-baseline");
|
||||
cdk.Tags.of(this).add("Owner", "adam@seahavenind.com");
|
||||
cdk.Tags.of(this).add("Environment", "prod");
|
||||
|
|
|
|||
87
lib/backup-offsite-stack.ts
Normal file
87
lib/backup-offsite-stack.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as kms from "aws-cdk-lib/aws-kms";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as backup from "aws-cdk-lib/aws-backup";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
/**
|
||||
* Offsite AWS Backup vault for Sea Haven (account 328440206208), in us-west-2.
|
||||
*
|
||||
* This is the Copy3 / offsite leg of the 3-2-1 strategy and the only immutable
|
||||
* recovery path in the account. The primary plan (see backup-stack.ts, us-east-1)
|
||||
* copies recovery points here cross-region. Closes audit finding C-7 together
|
||||
* with backup-stack.
|
||||
*
|
||||
* Vault Lock is GOVERNANCE mode for now (minRetention only, no `changeableFor`):
|
||||
* recovery points cannot be silently deleted, but a principal with explicit
|
||||
* `backup:DeleteRecoveryPoint` / `backup:DeleteBackupVaultLockConfiguration`
|
||||
* permission can still intervene while we validate the plan. Graduate to
|
||||
* COMPLIANCE mode later by adding `changeableFor` (irreversible after the
|
||||
* cooling-off window) — a one-line change + redeploy.
|
||||
*/
|
||||
export class BackupOffsiteStack extends cdk.Stack {
|
||||
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
// CMK encrypting offsite recovery points (rotation on; RETAIN so a stack
|
||||
// teardown never strands/destroys the only immutable copy).
|
||||
const vaultKey = new kms.Key(this, "OffsiteVaultKey", {
|
||||
alias: "backup-offsite-vault",
|
||||
description: "Encrypts offsite AWS Backup recovery points (us-west-2)",
|
||||
enableKeyRotation: true,
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
|
||||
// The L2 BackupVault does NOT grant the backup service use of a customer
|
||||
// CMK — without this, cross-region COPY jobs of encrypted RDS/EBS recovery
|
||||
// points fail (and silently, with no CloudTrail). Grant backup.amazonaws.com
|
||||
// the minimum KMS actions on this destination key, incl. CreateGrant.
|
||||
vaultKey.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AllowAwsBackupUseOfTheKey",
|
||||
principals: [new iam.ServicePrincipal("backup.amazonaws.com")],
|
||||
// Action set matches AWS's documented Backup vault-key policy; scoped
|
||||
// to this account so only this account's Backup service can use it.
|
||||
actions: [
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey",
|
||||
"kms:GenerateDataKeyWithoutPlaintext",
|
||||
"kms:ReEncrypt*",
|
||||
"kms:DescribeKey",
|
||||
],
|
||||
resources: ["*"],
|
||||
conditions: { StringEquals: { "aws:SourceAccount": this.account } },
|
||||
})
|
||||
);
|
||||
vaultKey.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AllowAwsBackupCreateGrant",
|
||||
principals: [new iam.ServicePrincipal("backup.amazonaws.com")],
|
||||
actions: ["kms:CreateGrant"],
|
||||
resources: ["*"],
|
||||
conditions: {
|
||||
Bool: { "kms:GrantIsForAWSResource": "true" },
|
||||
StringEquals: { "aws:SourceAccount": this.account },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
new backup.BackupVault(this, "OffsiteVault", {
|
||||
backupVaultName: "seahaven-offsite",
|
||||
encryptionKey: vaultKey,
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
// Governance-mode Vault Lock: no `changeableFor`, so it stays adjustable.
|
||||
lockConfiguration: {
|
||||
minRetention: cdk.Duration.days(30),
|
||||
},
|
||||
});
|
||||
|
||||
cdk.Tags.of(this).add("Project", "account-baseline");
|
||||
cdk.Tags.of(this).add("Owner", "adam@seahavenind.com");
|
||||
cdk.Tags.of(this).add("Environment", "prod");
|
||||
cdk.Tags.of(this).add("ManagedBy", "cdk");
|
||||
|
||||
new cdk.CfnOutput(this, "OffsiteVaultName", { value: "seahaven-offsite" });
|
||||
new cdk.CfnOutput(this, "OffsiteVaultKmsKeyArn", { value: vaultKey.keyArn });
|
||||
}
|
||||
}
|
||||
250
lib/backup-stack.ts
Normal file
250
lib/backup-stack.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as kms from "aws-cdk-lib/aws-kms";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as events from "aws-cdk-lib/aws-events";
|
||||
import * as backup from "aws-cdk-lib/aws-backup";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
/**
|
||||
* Primary AWS Backup vault + plan for Sea Haven (account 328440206208), us-east-1.
|
||||
*
|
||||
* Closes audit finding C-7 (AWS Backup entirely unused) together with
|
||||
* backup-offsite-stack. Phase 1 ("critical data first"): protect the data
|
||||
* stores with no offsite leg today and copy each recovery point cross-region
|
||||
* to the GOVERNANCE-locked `seahaven-offsite` vault (us-west-2).
|
||||
*
|
||||
* Coexistence: this SUPPLEMENTS the existing EBS DLM snapshots and DynamoDB
|
||||
* PITR — it does not replace them. It adds the missing Copy3 (offsite) +
|
||||
* immutability leg. The DLM/PITR overlap is rationalized in a later phase.
|
||||
*
|
||||
* Selection is by explicit ARN (not tag-based) so we don't have to tag — and
|
||||
* drift — resources owned by other stacks (proposal-system, payments-dashboard).
|
||||
* Switch to tag-based selection when expanding past the phase-1 set.
|
||||
*
|
||||
* PRE-DEPLOY GATES (validate before the first scheduled run):
|
||||
* - S3 backup requires bucket versioning. `accounting.seahaven.com` already
|
||||
* has it (audit C-9); `seahaven-payments-csv-328440206208` and
|
||||
* `google-workspace-seahavenind.com` must have versioning enabled first or
|
||||
* their jobs fail silently (folds in audit H-21).
|
||||
* - `database-1` is unencrypted (audit H-19). Cross-region copy of an
|
||||
* unencrypted RDS recovery point may fail or land unencrypted. Smoke-test
|
||||
* an on-demand backup of `database-1` FIRST and confirm the copy job to
|
||||
* us-west-2 succeeds; if not, encrypt database-1 (H-19) or drop it from the
|
||||
* copy until then.
|
||||
* - DynamoDB PITR (H-7) is independent of this plan; enable it on the two
|
||||
* tables for between-window point-in-time recovery.
|
||||
*/
|
||||
export class BackupStack extends cdk.Stack {
|
||||
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
// CMK encrypting the primary (operational) vault. RETAIN + rotation.
|
||||
const vaultKey = new kms.Key(this, "PrimaryVaultKey", {
|
||||
alias: "backup-primary-vault",
|
||||
description: "Encrypts primary AWS Backup recovery points (us-east-1)",
|
||||
enableKeyRotation: true,
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
|
||||
// The L2 BackupVault does NOT grant the backup service use of a customer
|
||||
// CMK; the default key policy only delegates to account IAM. Grant
|
||||
// backup.amazonaws.com the minimum KMS actions (incl. CreateGrant for
|
||||
// RDS/EBS recovery points) so backup jobs can write to this vault.
|
||||
vaultKey.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AllowAwsBackupUseOfTheKey",
|
||||
principals: [new iam.ServicePrincipal("backup.amazonaws.com")],
|
||||
// Action set matches AWS's documented Backup vault-key policy; scoped
|
||||
// to this account so only this account's Backup service can use it.
|
||||
actions: [
|
||||
"kms:Decrypt",
|
||||
"kms:GenerateDataKey",
|
||||
"kms:GenerateDataKeyWithoutPlaintext",
|
||||
"kms:ReEncrypt*",
|
||||
"kms:DescribeKey",
|
||||
],
|
||||
resources: ["*"],
|
||||
conditions: { StringEquals: { "aws:SourceAccount": this.account } },
|
||||
})
|
||||
);
|
||||
vaultKey.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AllowAwsBackupCreateGrant",
|
||||
principals: [new iam.ServicePrincipal("backup.amazonaws.com")],
|
||||
actions: ["kms:CreateGrant"],
|
||||
resources: ["*"],
|
||||
conditions: {
|
||||
Bool: { "kms:GrantIsForAWSResource": "true" },
|
||||
StringEquals: { "aws:SourceAccount": this.account },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Primary vault is intentionally NOT locked — it is the working copy; the
|
||||
// offsite vault carries the immutability guarantee.
|
||||
const primaryVault = new backup.BackupVault(this, "PrimaryVault", {
|
||||
backupVaultName: "seahaven-primary",
|
||||
encryptionKey: vaultKey,
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
|
||||
// Cross-region copy destination, referenced by literal ARN (the offsite
|
||||
// stack is in another region; a literal ARN avoids crossRegionReferences /
|
||||
// SSM exports). Stack ordering is enforced via addDependency in bin/app.ts.
|
||||
const offsiteVault = backup.BackupVault.fromBackupVaultArn(
|
||||
this,
|
||||
"OffsiteVaultRef",
|
||||
`arn:aws:backup:us-west-2:${this.account}:backup-vault:seahaven-offsite`
|
||||
);
|
||||
|
||||
// AWS Backup service role. Explicit (not auto-generated) because S3 backup
|
||||
// needs the S3-specific managed policy on top of the standard backup one.
|
||||
// Least-privilege: BACKUP + S3-backup only. Restore policies
|
||||
// (AWSBackupServiceRolePolicyForRestores / ...ForS3Restore) and
|
||||
// BackupSelection allowRestores are intentionally NOT granted — restores
|
||||
// are a deliberate, audited action and will get their own scoped role/path
|
||||
// once a restore-test process exists (cross-review F-1/F-2). A known role
|
||||
// name lets the deploy role's iam:PassRole be scoped to this exact ARN.
|
||||
// NOTE: creating this role is an IAM change → Sea Haven cross-review gate.
|
||||
const backupRole = new iam.Role(this, "BackupRole", {
|
||||
roleName: "seahaven-backup-service-role",
|
||||
assumedBy: new iam.ServicePrincipal("backup.amazonaws.com"),
|
||||
description: "AWS Backup service role (backup-only) for seahaven-primary",
|
||||
managedPolicies: [
|
||||
iam.ManagedPolicy.fromAwsManagedPolicyName(
|
||||
"service-role/AWSBackupServiceRolePolicyForBackup"
|
||||
),
|
||||
iam.ManagedPolicy.fromAwsManagedPolicyName(
|
||||
"AWSBackupServiceRolePolicyForS3Backup"
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
// Daily backup → primary vault (35d), cross-region copy → offsite (90d).
|
||||
const plan = new backup.BackupPlan(this, "Plan", {
|
||||
backupPlanName: "seahaven-critical-daily",
|
||||
backupVault: primaryVault,
|
||||
backupPlanRules: [
|
||||
new backup.BackupPlanRule({
|
||||
ruleName: "daily-crr-offsite",
|
||||
backupVault: primaryVault,
|
||||
// 06:00 UTC — offset from the file-share DLM run.
|
||||
scheduleExpression: events.Schedule.cron({ hour: "6", minute: "0" }),
|
||||
startWindow: cdk.Duration.hours(1),
|
||||
completionWindow: cdk.Duration.hours(6),
|
||||
deleteAfter: cdk.Duration.days(35),
|
||||
copyActions: [
|
||||
{
|
||||
destinationBackupVault: offsiteVault,
|
||||
deleteAfter: cdk.Duration.days(90),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Phase-1 critical set, by explicit ARN (identifiers verified against the
|
||||
// live account 2026-05-29).
|
||||
plan.addSelection("CriticalResources", {
|
||||
backupSelectionName: "critical-data",
|
||||
role: backupRole,
|
||||
// allowRestores omitted (defaults false) — backup-only, see role comment.
|
||||
resources: [
|
||||
// RDS. database-1 was retired 2026-06-03 (audit H-19: idle SQL Server
|
||||
// Express, snapshot-and-delete) — its final recovery point lives in the
|
||||
// offsite vault; removed from the selection so backup jobs don't fail on
|
||||
// a missing resource.
|
||||
backup.BackupResource.fromArn(
|
||||
`arn:aws:rds:us-east-1:${this.account}:db:proposal-system-db`
|
||||
),
|
||||
// DynamoDB (financial)
|
||||
backup.BackupResource.fromArn(
|
||||
`arn:aws:dynamodb:us-east-1:${this.account}:table/PaymentsDashboard`
|
||||
),
|
||||
backup.BackupResource.fromArn(
|
||||
`arn:aws:dynamodb:us-east-1:${this.account}:table/purchase-orders`
|
||||
),
|
||||
// S3 (single-copy critical buckets) — versioning required (see header)
|
||||
backup.BackupResource.fromArn("arn:aws:s3:::accounting.seahaven.com"),
|
||||
backup.BackupResource.fromArn(
|
||||
"arn:aws:s3:::seahaven-payments-csv-328440206208"
|
||||
),
|
||||
backup.BackupResource.fromArn(
|
||||
"arn:aws:s3:::google-workspace-seahavenind.com"
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
// Phase-2 expansion (audit Day 4): bring the remaining DynamoDB tables and
|
||||
// all in-use EBS volumes under the same daily plan + cross-region copy to
|
||||
// the locked offsite vault ("offsite for everything"). Same role and rule
|
||||
// as phase-1; a separate selection keeps the phase-1 critical set readable.
|
||||
//
|
||||
// Still EXPLICIT-ARN (not tag-based) on purpose: the file-share volumes are
|
||||
// standalone CDK-managed (RETAIN) and the DynamoDB tables are owned by other
|
||||
// stacks, so tagging them here would drift those stacks — the same reason
|
||||
// phase-1 avoided tags. Tradeoff: if a volume is replaced (new vol-id) it
|
||||
// silently drops from this selection; scheduled drift detection + the audit
|
||||
// re-run are the backstop. Identifiers verified against the live account
|
||||
// 2026-06-03.
|
||||
//
|
||||
// Excluded by intent: the ledgerflow tables — the whole LedgerFlow stack
|
||||
// was decommissioned 2026-06-03 (audit Day 4), so they no longer exist.
|
||||
// database-1 was retired the same day and removed from the phase-1 selection
|
||||
// above (audit H-19).
|
||||
plan.addSelection("Phase2Resources", {
|
||||
backupSelectionName: "phase2-offsite-everything",
|
||||
role: backupRole,
|
||||
resources: [
|
||||
// DynamoDB — all remaining tables (15)
|
||||
...[
|
||||
"SiteAssignments",
|
||||
"VendorReplies",
|
||||
"WorkOrderComments",
|
||||
"WorkOrders",
|
||||
"afterhours-shifts",
|
||||
"exec-aide",
|
||||
"front-sla-alerts",
|
||||
"internal-portal-data",
|
||||
"last-war-bot",
|
||||
"meal-order-manager-orders",
|
||||
"pending-site-review",
|
||||
"seahaven-conversations",
|
||||
"seahaven-unanswered-questions",
|
||||
"verified-sites",
|
||||
].map((t) =>
|
||||
backup.BackupResource.fromArn(
|
||||
`arn:aws:dynamodb:us-east-1:${this.account}:table/${t}`
|
||||
)
|
||||
),
|
||||
// EBS — all 9 in-use volumes (unencrypted sources land encrypted at the
|
||||
// vault CMK, as the C-7 database-1 smoke-test confirmed)
|
||||
...[
|
||||
"vol-05cb0eb5c145d799b", // SeaHavenIndustries-dev
|
||||
"vol-054cf918f227d88f6", // file-share (20 GiB)
|
||||
"vol-00f05a5a809697ce5", // forgejo
|
||||
"vol-04d951cccacc435b5", // file-share NAS (500 GiB)
|
||||
"vol-07094902194638fff", // syslog-server
|
||||
"vol-0488e0bad1f9afbfb", // apm-wo-analysis grafana
|
||||
"vol-0c2cbe9e71517a517", // Mutual Aid Data
|
||||
"vol-0fe224f13812f47e7", // jump box
|
||||
"vol-0f0c167f3d7f85542", // last-war-rankings
|
||||
].map((v) =>
|
||||
backup.BackupResource.fromArn(
|
||||
`arn:aws:ec2:us-east-1:${this.account}:volume/${v}`
|
||||
)
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
cdk.Tags.of(this).add("Project", "account-baseline");
|
||||
cdk.Tags.of(this).add("Owner", "adam@seahavenind.com");
|
||||
cdk.Tags.of(this).add("Environment", "prod");
|
||||
cdk.Tags.of(this).add("ManagedBy", "cdk");
|
||||
|
||||
new cdk.CfnOutput(this, "PrimaryVaultName", { value: "seahaven-primary" });
|
||||
new cdk.CfnOutput(this, "PrimaryVaultKmsKeyArn", { value: vaultKey.keyArn });
|
||||
new cdk.CfnOutput(this, "BackupPlanId", { value: plan.backupPlanId });
|
||||
new cdk.CfnOutput(this, "BackupRoleArn", { value: backupRole.roleArn });
|
||||
}
|
||||
}
|
||||
108
lib/bedrock-logging.ts
Normal file
108
lib/bedrock-logging.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as logs from "aws-cdk-lib/aws-logs";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
/**
|
||||
* Destinations + delivery role for Bedrock model invocation logging (audit
|
||||
* H-20). `seahaven-alex` is employee-facing and returns payments, invoices,
|
||||
* WO/PO, and HR/SA8000 data — model I/O needs an audit trail.
|
||||
*
|
||||
* CloudFormation has no resource type for the logging configuration itself
|
||||
* (account-level `PutModelInvocationLoggingConfiguration`), so — like the
|
||||
* Config recorder (INFRA-17) — the toggle is applied via CLI after deploy:
|
||||
*
|
||||
* aws bedrock put-model-invocation-logging-configuration --logging-config '{
|
||||
* "cloudWatchConfig": {
|
||||
* "logGroupName": "<BedrockInvocationLogGroup>",
|
||||
* "roleArn": "<BedrockLoggingRole ARN>",
|
||||
* "largeDataDeliveryS3Config": {"bucketName": "<bucket>", "keyPrefix": "large-payloads"}
|
||||
* },
|
||||
* "s3Config": {"bucketName": "<bucket>", "keyPrefix": "invocation-logs"},
|
||||
* "textDataDeliveryEnabled": true,
|
||||
* "imageDataDeliveryEnabled": true,
|
||||
* "embeddingDataDeliveryEnabled": false
|
||||
* }'
|
||||
*/
|
||||
export class BedrockLogging extends Construct {
|
||||
public readonly bucket: s3.Bucket;
|
||||
public readonly logGroup: logs.LogGroup;
|
||||
public readonly deliveryRole: iam.Role;
|
||||
|
||||
constructor(scope: Construct, id: string) {
|
||||
super(scope, id);
|
||||
|
||||
const stack = cdk.Stack.of(this);
|
||||
|
||||
this.bucket = new s3.Bucket(this, "InvocationLogsBucket", {
|
||||
bucketName: `seahaven-bedrock-invocation-logs-${stack.account}`,
|
||||
encryption: s3.BucketEncryption.S3_MANAGED,
|
||||
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
||||
enforceSSL: true,
|
||||
versioned: false,
|
||||
lifecycleRules: [
|
||||
{
|
||||
id: "transition-and-expire",
|
||||
transitions: [
|
||||
{
|
||||
storageClass: s3.StorageClass.GLACIER,
|
||||
transitionAfter: cdk.Duration.days(90),
|
||||
},
|
||||
],
|
||||
expiration: cdk.Duration.days(365),
|
||||
abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
|
||||
},
|
||||
],
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
|
||||
// Bedrock writes invocation logs to S3 directly via bucket policy — no role.
|
||||
this.bucket.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AmazonBedrockLogsWrite",
|
||||
effect: iam.Effect.ALLOW,
|
||||
principals: [new iam.ServicePrincipal("bedrock.amazonaws.com")],
|
||||
actions: ["s3:PutObject"],
|
||||
resources: [this.bucket.arnForObjects("*")],
|
||||
conditions: {
|
||||
StringEquals: { "aws:SourceAccount": stack.account },
|
||||
ArnLike: {
|
||||
"aws:SourceArn": `arn:aws:bedrock:${stack.region}:${stack.account}:*`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
this.logGroup = new logs.LogGroup(this, "InvocationLogGroup", {
|
||||
logGroupName: "/aws/bedrock/model-invocations",
|
||||
retention: logs.RetentionDays.THREE_MONTHS,
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
|
||||
// CloudWatch delivery requires a role Bedrock can assume, scoped to this
|
||||
// account/source and to the one log group.
|
||||
this.deliveryRole = new iam.Role(this, "DeliveryRole", {
|
||||
roleName: "seahaven-bedrock-invocation-logging",
|
||||
assumedBy: new iam.ServicePrincipal("bedrock.amazonaws.com", {
|
||||
conditions: {
|
||||
StringEquals: { "aws:SourceAccount": stack.account },
|
||||
ArnLike: {
|
||||
"aws:SourceArn": `arn:aws:bedrock:${stack.region}:${stack.account}:*`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
this.deliveryRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
actions: ["logs:CreateLogStream", "logs:PutLogEvents"],
|
||||
resources: [this.logGroup.logGroupArn, `${this.logGroup.logGroupArn}:log-stream:*`],
|
||||
}),
|
||||
);
|
||||
|
||||
new cdk.CfnOutput(this, "BedrockLogBucketName", { value: this.bucket.bucketName });
|
||||
new cdk.CfnOutput(this, "BedrockLogGroupName", { value: this.logGroup.logGroupName });
|
||||
new cdk.CfnOutput(this, "BedrockLoggingRoleArn", { value: this.deliveryRole.roleArn });
|
||||
}
|
||||
}
|
||||
220
lib/cis-monitoring.ts
Normal file
220
lib/cis-monitoring.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as logs from "aws-cdk-lib/aws-logs";
|
||||
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
|
||||
import * as cwactions from "aws-cdk-lib/aws-cloudwatch-actions";
|
||||
import * as sns from "aws-cdk-lib/aws-sns";
|
||||
import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions";
|
||||
import * as kms from "aws-cdk-lib/aws-kms";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
/**
|
||||
* CIS AWS Foundations Benchmark v3.0 Section 4 — Monitoring (audit H-1).
|
||||
*
|
||||
* 15 metric filters on the account CloudTrail log group, each backed by a
|
||||
* CloudWatch alarm that notifies a dedicated SNS topic. Closes CIS 4.1–4.15.
|
||||
* (4.16 "Security Hub enabled" is not a metric filter — done Day 1, H-4.)
|
||||
*
|
||||
* Alarms fire on ALARM only (no OK/recovery actions) per Sea Haven preference.
|
||||
*/
|
||||
|
||||
// The account CloudTrail (C-1) delivers to this CloudWatch Logs group. It is
|
||||
// created by the L2 cloudtrail.Trail in account-baseline-stack.ts; we import it
|
||||
// by name rather than replace it, so the live audit trail is never disrupted.
|
||||
// Stable as long as the Trail is not recreated.
|
||||
const TRAIL_LOG_GROUP_NAME =
|
||||
"seahaven-account-baseline-TrailLogGroup4CBE3AF5-e7hMDCzj8e4d";
|
||||
|
||||
interface CisControl {
|
||||
readonly id: string;
|
||||
readonly metricName: string;
|
||||
readonly pattern: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
const CIS_CONTROLS: CisControl[] = [
|
||||
{
|
||||
id: "UnauthorizedApiCalls",
|
||||
metricName: "UnauthorizedAPICalls",
|
||||
pattern:
|
||||
'{ ($.errorCode = "*UnauthorizedOperation") || ($.errorCode = "AccessDenied*") && ($.sourceIPAddress != "delivery.logs.amazonaws.com") && ($.eventName != "HeadBucket") }',
|
||||
description: "CIS 4.1 — unauthorized API calls",
|
||||
},
|
||||
{
|
||||
id: "ConsoleSigninNoMfa",
|
||||
metricName: "ConsoleSigninWithoutMFA",
|
||||
pattern:
|
||||
'{ ($.eventName = "ConsoleLogin") && ($.additionalEventData.MFAUsed != "Yes") && ($.userIdentity.type = "IAMUser") && ($.responseElements.ConsoleLogin = "Success") }',
|
||||
description: "CIS 4.2 — console sign-in without MFA",
|
||||
},
|
||||
{
|
||||
id: "RootAccountUsage",
|
||||
metricName: "RootAccountUsage",
|
||||
pattern:
|
||||
'{ $.userIdentity.type = "Root" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != "AwsServiceEvent" }',
|
||||
description: "CIS 4.3 — root account usage",
|
||||
},
|
||||
{
|
||||
id: "IamPolicyChanges",
|
||||
metricName: "IAMPolicyChanges",
|
||||
pattern:
|
||||
"{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}",
|
||||
description: "CIS 4.4 — IAM policy changes",
|
||||
},
|
||||
{
|
||||
id: "CloudTrailConfigChanges",
|
||||
metricName: "CloudTrailConfigChanges",
|
||||
pattern:
|
||||
"{ ($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging) }",
|
||||
description: "CIS 4.5 — CloudTrail configuration changes",
|
||||
},
|
||||
{
|
||||
id: "ConsoleAuthFailures",
|
||||
metricName: "ConsoleAuthenticationFailures",
|
||||
pattern:
|
||||
'{ ($.eventName = ConsoleLogin) && ($.errorMessage = "Failed authentication") }',
|
||||
description: "CIS 4.6 — console authentication failures",
|
||||
},
|
||||
{
|
||||
id: "CmkDisableOrDelete",
|
||||
metricName: "CMKDisableOrScheduledDelete",
|
||||
pattern:
|
||||
"{ ($.eventSource = kms.amazonaws.com) && (($.eventName = DisableKey) || ($.eventName = ScheduleKeyDeletion)) }",
|
||||
description: "CIS 4.7 — disabling or scheduled deletion of CMKs",
|
||||
},
|
||||
{
|
||||
id: "S3BucketPolicyChanges",
|
||||
metricName: "S3BucketPolicyChanges",
|
||||
pattern:
|
||||
"{ ($.eventSource = s3.amazonaws.com) && (($.eventName = PutBucketAcl) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketReplication) || ($.eventName = DeleteBucketPolicy) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketReplication)) }",
|
||||
description: "CIS 4.8 — S3 bucket policy changes",
|
||||
},
|
||||
{
|
||||
id: "ConfigChanges",
|
||||
metricName: "AWSConfigChanges",
|
||||
pattern:
|
||||
"{ ($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder)) }",
|
||||
description: "CIS 4.9 — AWS Config configuration changes",
|
||||
},
|
||||
{
|
||||
id: "SecurityGroupChanges",
|
||||
metricName: "SecurityGroupChanges",
|
||||
pattern:
|
||||
"{ ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) }",
|
||||
description: "CIS 4.10 — security group changes",
|
||||
},
|
||||
{
|
||||
id: "NaclChanges",
|
||||
metricName: "NetworkACLChanges",
|
||||
pattern:
|
||||
"{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }",
|
||||
description: "CIS 4.11 — network ACL changes",
|
||||
},
|
||||
{
|
||||
id: "NetworkGatewayChanges",
|
||||
metricName: "NetworkGatewayChanges",
|
||||
pattern:
|
||||
"{ ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }",
|
||||
description: "CIS 4.12 — network gateway changes",
|
||||
},
|
||||
{
|
||||
id: "RouteTableChanges",
|
||||
metricName: "RouteTableChanges",
|
||||
pattern:
|
||||
"{ ($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable) }",
|
||||
description: "CIS 4.13 — route table changes",
|
||||
},
|
||||
{
|
||||
id: "VpcChanges",
|
||||
metricName: "VPCChanges",
|
||||
pattern:
|
||||
"{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }",
|
||||
description: "CIS 4.14 — VPC changes",
|
||||
},
|
||||
{
|
||||
id: "OrganizationsChanges",
|
||||
metricName: "OrganizationsChanges",
|
||||
pattern:
|
||||
'{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = "AcceptHandshake") || ($.eventName = "AttachPolicy") || ($.eventName = "CreateAccount") || ($.eventName = "CreateOrganizationalUnit") || ($.eventName = "CreatePolicy") || ($.eventName = "DeclineHandshake") || ($.eventName = "DeleteOrganization") || ($.eventName = "DeleteOrganizationalUnit") || ($.eventName = "DeletePolicy") || ($.eventName = "DetachPolicy") || ($.eventName = "DisablePolicyType") || ($.eventName = "EnablePolicyType") || ($.eventName = "InviteAccountToOrganization") || ($.eventName = "LeaveOrganization") || ($.eventName = "MoveAccount") || ($.eventName = "RemoveAccountFromOrganization") || ($.eventName = "UpdatePolicy") || ($.eventName = "UpdateOrganizationalUnit")) }',
|
||||
description: "CIS 4.15 — AWS Organizations changes",
|
||||
},
|
||||
];
|
||||
|
||||
export interface CisMonitoringProps {
|
||||
/** Email subscribed to the CIS alarm topic. */
|
||||
readonly alarmEmail: string;
|
||||
}
|
||||
|
||||
export class CisMonitoring extends Construct {
|
||||
constructor(scope: Construct, id: string, props: CisMonitoringProps) {
|
||||
super(scope, id);
|
||||
|
||||
// Customer-managed key for alarm topics (audit L-14). The AWS-managed
|
||||
// alias/aws/sns key CANNOT be used here: its key policy can't grant
|
||||
// cloudwatch.amazonaws.com, so CloudWatch alarms silently fail to publish
|
||||
// to topics it encrypts — which is exactly what these topics receive.
|
||||
// Also used by the unmanaged site-alerts topic (set via CLI; ARN output below).
|
||||
const alarmTopicKey = new kms.Key(this, "AlarmTopicKey", {
|
||||
alias: "seahaven-alarm-topics",
|
||||
description:
|
||||
"SSE for SNS alarm topics; grants CloudWatch alarms publish-side usage",
|
||||
enableKeyRotation: true,
|
||||
});
|
||||
alarmTopicKey.addToResourcePolicy(
|
||||
new cdk.aws_iam.PolicyStatement({
|
||||
sid: "AllowCloudWatchAlarmsUse",
|
||||
principals: [new cdk.aws_iam.ServicePrincipal("cloudwatch.amazonaws.com")],
|
||||
actions: ["kms:GenerateDataKey*", "kms:Decrypt", "kms:DescribeKey"],
|
||||
resources: ["*"],
|
||||
conditions: {
|
||||
StringEquals: { "aws:SourceAccount": cdk.Stack.of(this).account },
|
||||
},
|
||||
})
|
||||
);
|
||||
new cdk.CfnOutput(this, "AlarmTopicKeyArn", { value: alarmTopicKey.keyArn });
|
||||
|
||||
// Dedicated topic for security/CIS alarms (audit H-1, L-14).
|
||||
const topic = new sns.Topic(this, "CisAlarmTopic", {
|
||||
topicName: "seahaven-cis-alarms",
|
||||
displayName: "Sea Haven CIS / security alarms",
|
||||
masterKey: alarmTopicKey,
|
||||
});
|
||||
topic.addSubscription(new subscriptions.EmailSubscription(props.alarmEmail));
|
||||
|
||||
const logGroup = logs.LogGroup.fromLogGroupName(
|
||||
this,
|
||||
"TrailLogGroup",
|
||||
TRAIL_LOG_GROUP_NAME
|
||||
);
|
||||
|
||||
for (const c of CIS_CONTROLS) {
|
||||
const mf = new logs.MetricFilter(this, `${c.id}Filter`, {
|
||||
logGroup,
|
||||
filterPattern: logs.FilterPattern.literal(c.pattern),
|
||||
metricNamespace: "CISBenchmark",
|
||||
metricName: c.metricName,
|
||||
metricValue: "1",
|
||||
defaultValue: 0,
|
||||
});
|
||||
|
||||
const alarm = mf
|
||||
.metric({
|
||||
statistic: "Sum",
|
||||
period: cdk.Duration.minutes(5),
|
||||
})
|
||||
.createAlarm(this, `${c.id}Alarm`, {
|
||||
alarmName: `cis-${c.metricName}`,
|
||||
alarmDescription: c.description,
|
||||
threshold: 1,
|
||||
comparisonOperator:
|
||||
cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
|
||||
evaluationPeriods: 1,
|
||||
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||
});
|
||||
|
||||
// ALARM-only notification (no OK/recovery action) per Sea Haven preference.
|
||||
alarm.addAlarmAction(new cwactions.SnsAction(topic));
|
||||
}
|
||||
|
||||
new cdk.CfnOutput(this, "CisAlarmTopicArn", { value: topic.topicArn });
|
||||
}
|
||||
}
|
||||
191
lib/detective-controls.ts
Normal file
191
lib/detective-controls.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as guardduty from "aws-cdk-lib/aws-guardduty";
|
||||
import * as securityhub from "aws-cdk-lib/aws-securityhub";
|
||||
import * as accessanalyzer from "aws-cdk-lib/aws-accessanalyzer";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
/**
|
||||
* Account-level detective controls (audit Day 1).
|
||||
*
|
||||
* Closes:
|
||||
* H-2 AWS Config recorder + delivery channel (CIS 3.3/3.5)
|
||||
* H-3 GuardDuty detector
|
||||
* H-4 Security Hub with AWS FSBP + CIS v3.0 standards
|
||||
* M-5 IAM Access Analyzer (account-scoped external-access analyzer)
|
||||
*
|
||||
* Scope is us-east-1 only — all workloads live here (Adam's call, Day 1).
|
||||
* Multi-region coverage is a documented follow-up.
|
||||
*/
|
||||
export class DetectiveControls extends Construct {
|
||||
constructor(scope: Construct, id: string) {
|
||||
super(scope, id);
|
||||
|
||||
const stack = cdk.Stack.of(this);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// H-2 AWS Config
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Delivery bucket for Config snapshots/history. Private, TLS-only,
|
||||
// versioned, SSE-S3 (Config writes here; SSE-S3 avoids a second KMS-grant
|
||||
// failure mode and is sufficient — CIS does not require a CMK here).
|
||||
const configBucket = new s3.Bucket(this, "ConfigBucket", {
|
||||
bucketName: `seahaven-config-${stack.account}`,
|
||||
encryption: s3.BucketEncryption.S3_MANAGED,
|
||||
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
||||
enforceSSL: true,
|
||||
versioned: true,
|
||||
lifecycleRules: [
|
||||
{
|
||||
id: "expire-old-config",
|
||||
expiration: cdk.Duration.days(365),
|
||||
abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
|
||||
},
|
||||
],
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
|
||||
// Bucket policy that lets the Config service principal verify ownership
|
||||
// and deliver objects (scoped to this account, owner-full-control ACL).
|
||||
configBucket.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AWSConfigBucketPermissionsCheck",
|
||||
effect: iam.Effect.ALLOW,
|
||||
principals: [new iam.ServicePrincipal("config.amazonaws.com")],
|
||||
actions: ["s3:GetBucketAcl", "s3:ListBucket"],
|
||||
resources: [configBucket.bucketArn],
|
||||
conditions: {
|
||||
StringEquals: { "aws:SourceAccount": stack.account },
|
||||
},
|
||||
})
|
||||
);
|
||||
configBucket.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AWSConfigBucketDelivery",
|
||||
effect: iam.Effect.ALLOW,
|
||||
principals: [new iam.ServicePrincipal("config.amazonaws.com")],
|
||||
actions: ["s3:PutObject"],
|
||||
resources: [
|
||||
configBucket.arnForObjects(`AWSLogs/${stack.account}/Config/*`),
|
||||
],
|
||||
conditions: {
|
||||
StringEquals: {
|
||||
"s3:x-amz-acl": "bucket-owner-full-control",
|
||||
"aws:SourceAccount": stack.account,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Recorder role — assumed by Config. AWS_ConfigRole grants the read/describe
|
||||
// permissions Config needs to record every resource type; the inline policy
|
||||
// grants delivery to the bucket above. **This role is the Day 1 cross-review
|
||||
// item (IAM change per CLAUDE.md).**
|
||||
const recorderRole = new iam.Role(this, "ConfigRecorderRole", {
|
||||
roleName: "seahaven-config-recorder-role",
|
||||
assumedBy: new iam.ServicePrincipal("config.amazonaws.com"),
|
||||
managedPolicies: [
|
||||
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWS_ConfigRole"),
|
||||
],
|
||||
});
|
||||
recorderRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "ConfigDeliveryToBucket",
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: ["s3:PutObject"],
|
||||
resources: [
|
||||
configBucket.arnForObjects(`AWSLogs/${stack.account}/Config/*`),
|
||||
],
|
||||
conditions: {
|
||||
StringEquals: { "s3:x-amz-acl": "bucket-owner-full-control" },
|
||||
},
|
||||
})
|
||||
);
|
||||
recorderRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "ConfigBucketAcl",
|
||||
effect: iam.Effect.ALLOW,
|
||||
actions: ["s3:GetBucketAcl"],
|
||||
resources: [configBucket.bucketArn],
|
||||
})
|
||||
);
|
||||
|
||||
// NOTE — the Config recorder + delivery channel are provisioned via CLI,
|
||||
// not CloudFormation. The L1 AWS::Config::ConfigurationRecorder is a
|
||||
// stabilizing resource that will not reach CREATE_COMPLETE until recording
|
||||
// is active, which needs a delivery channel; the delivery channel cannot be
|
||||
// created until the recorder resource completes — a deadlock that hangs the
|
||||
// stack indefinitely (observed 2026-06-01). The role + delivery bucket above
|
||||
// stay in IaC (the role is the cross-reviewed IAM); the recorder/channel are
|
||||
// created with the commands documented in the README, referencing this role
|
||||
// ARN and bucket name (exported below).
|
||||
|
||||
new cdk.CfnOutput(this, "ConfigRecorderRoleArn", {
|
||||
value: recorderRole.roleArn,
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// H-3 GuardDuty
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
new guardduty.CfnDetector(this, "GuardDutyDetector", {
|
||||
enable: true,
|
||||
findingPublishingFrequency: "FIFTEEN_MINUTES",
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// H-4 Security Hub (FSBP + CIS v3.0)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// CIS/FSBP controls evaluate against the Config recording set up via CLI;
|
||||
// no CFN dependency is needed (findings populate once Config is recording).
|
||||
const hub = new securityhub.CfnHub(this, "SecurityHub", {
|
||||
enableDefaultStandards: false,
|
||||
controlFindingGenerator: "SECURITY_CONTROL",
|
||||
autoEnableControls: true,
|
||||
});
|
||||
|
||||
const fsbpArn = cdk.Arn.format(
|
||||
{
|
||||
service: "securityhub",
|
||||
region: stack.region,
|
||||
account: "",
|
||||
resource: "standards",
|
||||
resourceName: "aws-foundational-security-best-practices/v/1.0.0",
|
||||
},
|
||||
stack
|
||||
);
|
||||
const cisArn = cdk.Arn.format(
|
||||
{
|
||||
service: "securityhub",
|
||||
region: stack.region,
|
||||
account: "",
|
||||
resource: "standards",
|
||||
resourceName: "cis-aws-foundations-benchmark/v/3.0.0",
|
||||
},
|
||||
stack
|
||||
);
|
||||
|
||||
const fsbp = new securityhub.CfnStandard(this, "StandardFSBP", {
|
||||
standardsArn: fsbpArn,
|
||||
});
|
||||
fsbp.node.addDependency(hub);
|
||||
|
||||
const cis = new securityhub.CfnStandard(this, "StandardCIS", {
|
||||
standardsArn: cisArn,
|
||||
});
|
||||
cis.node.addDependency(hub);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// M-5 IAM Access Analyzer (free, account-scoped external-access)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
new accessanalyzer.CfnAnalyzer(this, "AccountAnalyzer", {
|
||||
analyzerName: "seahaven-account-analyzer",
|
||||
type: "ACCOUNT",
|
||||
});
|
||||
|
||||
new cdk.CfnOutput(this, "ConfigBucketName", {
|
||||
value: configBucket.bucketName,
|
||||
});
|
||||
}
|
||||
}
|
||||
108
lib/flow-logs.ts
Normal file
108
lib/flow-logs.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as ec2 from "aws-cdk-lib/aws-ec2";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
/**
|
||||
* VPC flow logs for every VPC, delivered to a hardened S3 bucket (audit H-14,
|
||||
* CIS 3.9/5.6). S3 destination (not CloudWatch Logs) for cost — query
|
||||
* forensically via Athena. ALL traffic (accept + reject).
|
||||
*
|
||||
* S3 delivery needs no IAM role; instead the bucket policy grants the
|
||||
* `delivery.logs.amazonaws.com` service principal write access, scoped to this
|
||||
* account. That bucket policy is the Day 2 cross-review item.
|
||||
*/
|
||||
|
||||
// All 5 VPCs in 328440206208 / us-east-1 (4 custom + default), audit Agent 7.
|
||||
const VPC_IDS = [
|
||||
"vpc-061d66990b6a4d1fb",
|
||||
"vpc-0542a9e934b417d23",
|
||||
"vpc-062d200c68bd4ca0e",
|
||||
"vpc-0d3d4b67bd0cf8a68",
|
||||
"vpc-02c10a89d66f6f9b8",
|
||||
];
|
||||
|
||||
export class FlowLogs extends Construct {
|
||||
constructor(scope: Construct, id: string) {
|
||||
super(scope, id);
|
||||
|
||||
const stack = cdk.Stack.of(this);
|
||||
|
||||
const bucket = new s3.Bucket(this, "FlowLogsBucket", {
|
||||
bucketName: `seahaven-vpc-flow-logs-${stack.account}`,
|
||||
encryption: s3.BucketEncryption.S3_MANAGED,
|
||||
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
||||
enforceSSL: true,
|
||||
versioned: false,
|
||||
lifecycleRules: [
|
||||
{
|
||||
id: "transition-and-expire",
|
||||
transitions: [
|
||||
{
|
||||
storageClass: s3.StorageClass.GLACIER,
|
||||
transitionAfter: cdk.Duration.days(90),
|
||||
},
|
||||
],
|
||||
expiration: cdk.Duration.days(365),
|
||||
abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
|
||||
},
|
||||
],
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
});
|
||||
|
||||
// Log-delivery service permissions (scoped to this account) — the standard
|
||||
// VPC-flow-logs-to-S3 bucket policy.
|
||||
bucket.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AWSLogDeliveryWrite",
|
||||
effect: iam.Effect.ALLOW,
|
||||
principals: [new iam.ServicePrincipal("delivery.logs.amazonaws.com")],
|
||||
actions: ["s3:PutObject"],
|
||||
resources: [bucket.arnForObjects(`AWSLogs/${stack.account}/*`)],
|
||||
conditions: {
|
||||
StringEquals: {
|
||||
"s3:x-amz-acl": "bucket-owner-full-control",
|
||||
"aws:SourceAccount": stack.account,
|
||||
},
|
||||
ArnLike: {
|
||||
"aws:SourceArn": `arn:${stack.partition}:logs:${stack.region}:${stack.account}:*`,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
bucket.addToResourcePolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AWSLogDeliveryAclCheck",
|
||||
effect: iam.Effect.ALLOW,
|
||||
principals: [new iam.ServicePrincipal("delivery.logs.amazonaws.com")],
|
||||
// AWS's documented flow-logs-to-S3 policy uses GetBucketAcl only
|
||||
// (verified against flow-logs-s3-permissions.html); ListBucket is not
|
||||
// needed and would be over-permissioned.
|
||||
actions: ["s3:GetBucketAcl"],
|
||||
resources: [bucket.bucketArn],
|
||||
conditions: {
|
||||
StringEquals: { "aws:SourceAccount": stack.account },
|
||||
ArnLike: {
|
||||
"aws:SourceArn": `arn:${stack.partition}:logs:${stack.region}:${stack.account}:*`,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
VPC_IDS.forEach((vpcId, i) => {
|
||||
const flowLog = new ec2.CfnFlowLog(this, `FlowLog${i}`, {
|
||||
resourceId: vpcId,
|
||||
resourceType: "VPC",
|
||||
trafficType: "ALL",
|
||||
logDestinationType: "s3",
|
||||
logDestination: bucket.bucketArn,
|
||||
maxAggregationInterval: 600,
|
||||
tags: [{ key: "Name", value: `flow-log-${vpcId}` }],
|
||||
});
|
||||
flowLog.node.addDependency(bucket.policy!);
|
||||
});
|
||||
|
||||
new cdk.CfnOutput(this, "FlowLogsBucketName", { value: bucket.bucketName });
|
||||
}
|
||||
}
|
||||
86
lib/governance-toggles.ts
Normal file
86
lib/governance-toggles.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as budgets from "aws-cdk-lib/aws-budgets";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
export interface GovernanceTogglesProps {
|
||||
/** Monthly cost budget ceiling in USD. */
|
||||
readonly monthlyLimitUsd: number;
|
||||
/** Email that receives the budget threshold alerts. */
|
||||
readonly alertEmail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account-level governance toggles that *are* expressible as CloudFormation
|
||||
* (audit Day 1).
|
||||
*
|
||||
* Closes:
|
||||
* M-10 Monthly AWS Budget with 80% / 100% actual + 100% forecast alerts
|
||||
*
|
||||
* The remaining Day 1 governance items have no CloudFormation resource and are
|
||||
* applied via CLI + documented in the README runbook (Adam's call, Day 1):
|
||||
* M-6 Inspector2 enable (EC2 + Lambda + ECR)
|
||||
* M-3 EBS encryption-by-default
|
||||
* M-7 IAM account password policy
|
||||
* L-8 Billing-metrics preference (us-east-1)
|
||||
* M-11 Cost-allocation tag activation
|
||||
*/
|
||||
export class GovernanceToggles extends Construct {
|
||||
constructor(scope: Construct, id: string, props: GovernanceTogglesProps) {
|
||||
super(scope, id);
|
||||
|
||||
const subscriber = [
|
||||
{
|
||||
subscriptionType: "EMAIL",
|
||||
address: props.alertEmail,
|
||||
},
|
||||
];
|
||||
|
||||
new budgets.CfnBudget(this, "MonthlyCostBudget", {
|
||||
budget: {
|
||||
budgetName: "seahaven-monthly-cost",
|
||||
budgetType: "COST",
|
||||
timeUnit: "MONTHLY",
|
||||
budgetLimit: {
|
||||
amount: props.monthlyLimitUsd,
|
||||
unit: "USD",
|
||||
},
|
||||
},
|
||||
notificationsWithSubscribers: [
|
||||
{
|
||||
notification: {
|
||||
notificationType: "ACTUAL",
|
||||
comparisonOperator: "GREATER_THAN",
|
||||
threshold: 80,
|
||||
thresholdType: "PERCENTAGE",
|
||||
},
|
||||
subscribers: subscriber,
|
||||
},
|
||||
{
|
||||
notification: {
|
||||
notificationType: "ACTUAL",
|
||||
comparisonOperator: "GREATER_THAN",
|
||||
threshold: 100,
|
||||
thresholdType: "PERCENTAGE",
|
||||
},
|
||||
subscribers: subscriber,
|
||||
},
|
||||
{
|
||||
notification: {
|
||||
notificationType: "FORECASTED",
|
||||
comparisonOperator: "GREATER_THAN",
|
||||
threshold: 100,
|
||||
thresholdType: "PERCENTAGE",
|
||||
},
|
||||
subscribers: subscriber,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
cdk.Annotations.of(this).addInfo(
|
||||
"Budget alerts: 80%/100% actual + 100% forecast of $" +
|
||||
props.monthlyLimitUsd +
|
||||
" to " +
|
||||
props.alertEmail
|
||||
);
|
||||
}
|
||||
}
|
||||
50
lib/ses-monitoring.ts
Normal file
50
lib/ses-monitoring.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as ses from "aws-cdk-lib/aws-ses";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
/**
|
||||
* SES configuration set capturing bounce/complaint events (audit M-13).
|
||||
*
|
||||
* Gives reputation visibility beyond the suppression list by emitting bounce,
|
||||
* complaint, and reject events to CloudWatch metrics (dimensioned by config
|
||||
* set). Associating this set as the default on the live sending identities is a
|
||||
* follow-up CLI step (documented in the README) — creating it here does not
|
||||
* change current sending behaviour.
|
||||
*/
|
||||
export class SesMonitoring extends Construct {
|
||||
constructor(scope: Construct, id: string) {
|
||||
super(scope, id);
|
||||
|
||||
const configSetName = "seahaven-email-events";
|
||||
|
||||
const configSet = new ses.CfnConfigurationSet(this, "ConfigSet", {
|
||||
name: configSetName,
|
||||
reputationOptions: { reputationMetricsEnabled: true },
|
||||
});
|
||||
|
||||
const eventDest = new ses.CfnConfigurationSetEventDestination(
|
||||
this,
|
||||
"BounceComplaintDest",
|
||||
{
|
||||
configurationSetName: configSetName,
|
||||
eventDestination: {
|
||||
name: "bounce-complaint-cw",
|
||||
enabled: true,
|
||||
matchingEventTypes: ["bounce", "complaint", "reject"],
|
||||
cloudWatchDestination: {
|
||||
dimensionConfigurations: [
|
||||
{
|
||||
defaultDimensionValue: "none",
|
||||
dimensionName: "ses:configuration-set",
|
||||
dimensionValueSource: "messageTag",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
eventDest.node.addDependency(configSet);
|
||||
|
||||
new cdk.CfnOutput(this, "SesConfigSetName", { value: configSetName });
|
||||
}
|
||||
}
|
||||
76
lib/web-acl.ts
Normal file
76
lib/web-acl.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as wafv2 from "aws-cdk-lib/aws-wafv2";
|
||||
import * as ssm from "aws-cdk-lib/aws-ssm";
|
||||
import { Construct } from "constructs";
|
||||
|
||||
/**
|
||||
* Shared CloudFront WAF WebACL for Sea Haven app distributions (audit M-17).
|
||||
*
|
||||
* AWS managed rule groups (Common + Known Bad Inputs) plus an IP rate limit.
|
||||
* CLOUDFRONT-scope WebACLs must live in us-east-1 — which is where this stack
|
||||
* is — so it can be referenced by any app CloudFront distribution by ARN.
|
||||
*
|
||||
* The ARN is published to SSM (`/seahaven/waf/app-web-acl-arn`) so app stacks in
|
||||
* other repos can consume it via `{{resolve:ssm:...}}` without a hard CFN export.
|
||||
*/
|
||||
export class AppWebAcl extends Construct {
|
||||
constructor(scope: Construct, id: string) {
|
||||
super(scope, id);
|
||||
|
||||
const vis = (metric: string): wafv2.CfnWebACL.VisibilityConfigProperty => ({
|
||||
cloudWatchMetricsEnabled: true,
|
||||
sampledRequestsEnabled: true,
|
||||
metricName: metric,
|
||||
});
|
||||
|
||||
const webAcl = new wafv2.CfnWebACL(this, "AppWebAcl", {
|
||||
name: "seahaven-app-waf",
|
||||
scope: "CLOUDFRONT",
|
||||
defaultAction: { allow: {} },
|
||||
visibilityConfig: vis("seahaven-app-waf"),
|
||||
rules: [
|
||||
{
|
||||
name: "AWSCommonRuleSet",
|
||||
priority: 1,
|
||||
overrideAction: { none: {} },
|
||||
statement: {
|
||||
managedRuleGroupStatement: {
|
||||
vendorName: "AWS",
|
||||
name: "AWSManagedRulesCommonRuleSet",
|
||||
},
|
||||
},
|
||||
visibilityConfig: vis("AWSCommonRuleSet"),
|
||||
},
|
||||
{
|
||||
name: "AWSKnownBadInputs",
|
||||
priority: 2,
|
||||
overrideAction: { none: {} },
|
||||
statement: {
|
||||
managedRuleGroupStatement: {
|
||||
vendorName: "AWS",
|
||||
name: "AWSManagedRulesKnownBadInputsRuleSet",
|
||||
},
|
||||
},
|
||||
visibilityConfig: vis("AWSKnownBadInputs"),
|
||||
},
|
||||
{
|
||||
name: "RateLimitPerIp",
|
||||
priority: 3,
|
||||
action: { block: {} },
|
||||
statement: {
|
||||
rateBasedStatement: { limit: 2000, aggregateKeyType: "IP" },
|
||||
},
|
||||
visibilityConfig: vis("RateLimitPerIp"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
new ssm.StringParameter(this, "AppWebAclArnParam", {
|
||||
parameterName: "/seahaven/waf/app-web-acl-arn",
|
||||
stringValue: webAcl.attrArn,
|
||||
description: "ARN of the shared CloudFront WAF WebACL (audit M-17)",
|
||||
});
|
||||
|
||||
new cdk.CfnOutput(this, "AppWebAclArn", { value: webAcl.attrArn });
|
||||
}
|
||||
}
|
||||
59
package-lock.json
generated
59
package-lock.json
generated
|
|
@ -8,14 +8,14 @@
|
|||
"name": "seahaven-account-baseline",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "2.253.1",
|
||||
"aws-cdk-lib": "2.257.0",
|
||||
"constructs": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"app": "bin/app.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"aws-cdk": "^2.252.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
|
@ -141,13 +141,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/source-map-support": {
|
||||
|
|
@ -194,9 +194,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1125.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1125.0.tgz",
|
||||
"integrity": "sha512-QAvsE2XQMcyNOjMMqAS7eDADR9t6vcFcMQvhOmtLfDqgfJXSyTkHvzM5zgwZCdJ4FNqWr5Y/zXvL1Cv5ECKXwQ==",
|
||||
"version": "2.1126.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1126.0.tgz",
|
||||
"integrity": "sha512-uNoocb3vCPiAT3j9+SwL6pn/VVggHWBsgC2XpxyhNvYQYt6cE9BM/149GWwtdcwnLrPjnwW1+CV/5nSSh5dV+w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -207,9 +207,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib": {
|
||||
"version": "2.253.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.253.1.tgz",
|
||||
"integrity": "sha512-vy+hA15/ZfSQpivkNdlIn2ZDA2hesp3WJgmtIZJDFwu6xzwv7wH7glbAdu5xCHGcOjepOaTKZSvCPC6sN+0/Vw==",
|
||||
"version": "2.257.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.257.0.tgz",
|
||||
"integrity": "sha512-GoHfWklrBJcMwLtDlY64pvaT7cD2KyDXC8sik89DR6jHl6nQsBtYTKSJCM+C/k4jgXaecbv8myNX75FySejq0A==",
|
||||
"bundleDependencies": [
|
||||
"@balena/dockerignore",
|
||||
"@aws-cdk/cloud-assembly-api",
|
||||
|
|
@ -228,8 +228,8 @@
|
|||
"dependencies": {
|
||||
"@aws-cdk/asset-awscli-v1": "2.2.273",
|
||||
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1",
|
||||
"@aws-cdk/cloud-assembly-api": "^2.2.2",
|
||||
"@aws-cdk/cloud-assembly-schema": "^53.18.0",
|
||||
"@aws-cdk/cloud-assembly-api": "^2.2.4",
|
||||
"@aws-cdk/cloud-assembly-schema": "^53.25.0",
|
||||
"@balena/dockerignore": "^1.0.2",
|
||||
"case": "1.6.3",
|
||||
"fs-extra": "^11.3.3",
|
||||
|
|
@ -250,22 +250,29 @@
|
|||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
|
||||
"version": "2.2.2",
|
||||
"bundleDependencies": [
|
||||
"jsonschema",
|
||||
"semver"
|
||||
],
|
||||
"version": "2.2.4",
|
||||
"inBundle": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jsonschema": "~1.4.1",
|
||||
"semver": "^7.7.4"
|
||||
"jsonschema": "^1.5.0",
|
||||
"semver": "^7.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-cdk/cloud-assembly-schema": ">=53.15.0"
|
||||
"@aws-cdk/cloud-assembly-schema": ">=53.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"inBundle": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
|
||||
|
|
@ -372,7 +379,7 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk-lib/node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.2",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -697,9 +704,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"diff": "cdk diff"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"aws-cdk": "^2.252.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
"typescript": "~5.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "2.253.1",
|
||||
"aws-cdk-lib": "2.257.0",
|
||||
"constructs": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
scripts/cfn-stack-decommission.sh
Executable file
80
scripts/cfn-stack-decommission.sh
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# cfn-stack-decommission.sh — safely retire a CloudFormation/CDK stack.
|
||||
#
|
||||
# Reports first (default), acts only with --execute. The value is the pre-flight:
|
||||
# it predicts what will ORPHAN (DeletionPolicy: Retain resources survive a stack
|
||||
# delete) and what will BLOCK the delete (consumed exports, non-empty buckets),
|
||||
# so you don't discover surviving tables/buckets after the fact.
|
||||
#
|
||||
# Built from the Day 4 LedgerFlow decommission, where 4 of 5 DynamoDB tables +
|
||||
# 2 of 3 S3 buckets were RemovalPolicy.RETAIN and orphaned. See feedback memory
|
||||
# `feedback_cfn_decommission_and_remediation`.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/cfn-stack-decommission.sh [--profile NAME] [--execute] STACK
|
||||
#
|
||||
# (no --execute) REPORT only: termination protection, consumed exports,
|
||||
# Retain resources (orphans-to-be), in-stack S3 buckets.
|
||||
# --execute Disable termination protection, empty Delete-policy buckets,
|
||||
# delete the stack, wait, then delete the Retain orphans.
|
||||
#
|
||||
set -euo pipefail
|
||||
PROFILE_ARG=(); EXECUTE=0; STACK=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--profile) PROFILE_ARG=(--profile "$2"); shift 2 ;;
|
||||
--execute) EXECUTE=1; shift ;;
|
||||
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||
*) STACK="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
[[ -z "$STACK" ]] && { echo "usage: $0 [--profile NAME] [--execute] STACK" >&2; exit 2; }
|
||||
aws_() { aws "${PROFILE_ARG[@]}" "$@"; }
|
||||
R="us-east-1"
|
||||
|
||||
echo "== stack: $STACK =="
|
||||
aws_ cloudformation describe-stacks --stack-name "$STACK" --region "$R" \
|
||||
--query 'Stacks[0].{Status:StackStatus,TermProt:EnableTerminationProtection}' --output table
|
||||
|
||||
echo "-- consumed exports (any import BLOCKS the delete) --"
|
||||
BLOCKED=0
|
||||
for e in $(aws_ cloudformation list-exports --region "$R" \
|
||||
--query "Exports[?ExportingStackId && contains(ExportingStackId,':stack/$STACK/')].Name" --output text 2>/dev/null); do
|
||||
imp=$(aws_ cloudformation list-imports --export-name "$e" --region "$R" --query 'Imports' --output text 2>/dev/null || true)
|
||||
if [[ -n "$imp" && "$imp" != "None" ]]; then echo " BLOCK: export $e imported by: $imp"; BLOCKED=1; fi
|
||||
done
|
||||
[[ $BLOCKED -eq 0 ]] && echo " none"
|
||||
|
||||
echo "-- DeletionPolicy: Retain resources (these ORPHAN, survive the delete) --"
|
||||
TMP="$(aws_ cloudformation get-template --stack-name "$STACK" --region "$R" --query TemplateBody --output json)"
|
||||
echo "$TMP" | python3 -c '
|
||||
import json,sys
|
||||
res=json.load(sys.stdin).get("Resources",{})
|
||||
orphans=[(r.get("Type"),lid,r.get("Properties",{}).get("TableName") or r.get("Properties",{}).get("BucketName") or "")
|
||||
for lid,r in res.items() if r.get("DeletionPolicy")=="Retain"]
|
||||
[print(f" {t:<28} {lid} {name}") for t,lid,name in sorted(orphans)] or print(" none")
|
||||
'
|
||||
|
||||
echo "-- in-stack S3 buckets (non-empty Delete-policy buckets block; check auto-delete) --"
|
||||
for b in $(aws_ cloudformation list-stack-resources --stack-name "$STACK" --region "$R" \
|
||||
--query "StackResourceSummaries[?ResourceType=='AWS::S3::Bucket'].PhysicalResourceId" --output text 2>/dev/null); do
|
||||
n=$(aws_ s3api list-objects-v2 --bucket "$b" --max-items 1 --query 'KeyCount' --output text 2>/dev/null || echo "?")
|
||||
v=$(aws_ s3api get-bucket-versioning --bucket "$b" --query 'Status' --output text 2>/dev/null || echo "-")
|
||||
echo " $b objects~=$n versioning=$v"
|
||||
done
|
||||
|
||||
if [[ $EXECUTE -eq 0 ]]; then
|
||||
echo; echo "REPORT ONLY. Re-run with --execute to delete (after reviewing the orphans + blocks above)."
|
||||
exit 0
|
||||
fi
|
||||
[[ $BLOCKED -eq 1 ]] && { echo "ABORT: a consumed export blocks the delete (see above)." >&2; exit 1; }
|
||||
|
||||
read -r -p "EXECUTE decommission of '$STACK'? [y/N] " ans; [[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; exit 0; }
|
||||
aws_ cloudformation update-termination-protection --stack-name "$STACK" --no-enable-termination-protection --region "$R" >/dev/null 2>&1 || true
|
||||
echo "deleting stack..."
|
||||
aws_ cloudformation delete-stack --stack-name "$STACK" --region "$R"
|
||||
aws_ cloudformation wait stack-delete-complete --stack-name "$STACK" --region "$R"
|
||||
echo "stack deleted. Review the Retain orphans above and remove them with delete-table / delete-bucket"
|
||||
echo "(versioned buckets: purge all versions + delete-markers first — see the iam-user-delete sibling pattern)."
|
||||
109
scripts/iam-user-delete.sh
Executable file
109
scripts/iam-user-delete.sh
Executable file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# iam-user-delete.sh — fully delete one or more IAM users (account 328440206208).
|
||||
#
|
||||
# IAM refuses to delete a user that still has attached/inline policies, access
|
||||
# keys, group memberships, MFA devices, a login profile, signing certs, SSH keys,
|
||||
# or service-specific credentials. This tears all of that down in order, then
|
||||
# deletes the user. Built from the Day 4 audit cleanup (frappe/termius/ledgerflow,
|
||||
# then office_mac/home_desktop/Personal-laptop). See reference memory
|
||||
# `reference_identity_center_workmail` for the SSO context.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/iam-user-delete.sh [--profile NAME] [--yes] USER [USER ...]
|
||||
#
|
||||
# --profile NAME AWS CLI profile (default: $AWS_PROFILE or the default chain).
|
||||
# Post-SSO-cutover this is normally `amoussa-seahaven`.
|
||||
# --yes Skip the per-user confirmation prompt.
|
||||
#
|
||||
# Safety:
|
||||
# * Refuses to delete the user the current credentials authenticate as.
|
||||
# * Deactivates access keys before deleting them (a brief, reversible window
|
||||
# if you remove --yes and inspect between users).
|
||||
# * Prints each user's attachments before deleting so there is a record.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE_ARG=()
|
||||
ASSUME_YES=0
|
||||
USERS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--profile) PROFILE_ARG=(--profile "$2"); shift 2 ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||
*) USERS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
[[ ${#USERS[@]} -eq 0 ]] && { echo "usage: $0 [--profile NAME] [--yes] USER [USER ...]" >&2; exit 2; }
|
||||
|
||||
aws_() { aws "${PROFILE_ARG[@]}" "$@"; }
|
||||
|
||||
# Guard: never delete the identity we're running as.
|
||||
SELF_ARN="$(aws_ sts get-caller-identity --query Arn --output text)"
|
||||
echo "running as: $SELF_ARN"
|
||||
|
||||
delete_user() {
|
||||
local u="$1"
|
||||
if ! aws_ iam get-user --user-name "$u" >/dev/null 2>&1; then
|
||||
echo " $u: does not exist, skipping"; return 0
|
||||
fi
|
||||
if [[ "$SELF_ARN" == *":user/$u" ]]; then
|
||||
echo " $u: REFUSING — this is the identity you are authenticated as" >&2; return 1
|
||||
fi
|
||||
|
||||
echo "== $u =="
|
||||
echo " keys: $(aws_ iam list-access-keys --user-name "$u" --query 'AccessKeyMetadata[].AccessKeyId' --output text)"
|
||||
echo " attached: $(aws_ iam list-attached-user-policies --user-name "$u" --query 'AttachedPolicies[].PolicyName' --output text)"
|
||||
echo " inline: $(aws_ iam list-user-policies --user-name "$u" --query 'PolicyNames' --output text)"
|
||||
echo " groups: $(aws_ iam list-groups-for-user --user-name "$u" --query 'Groups[].GroupName' --output text)"
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p " delete user '$u'? [y/N] " ans
|
||||
[[ "$ans" =~ ^[Yy]$ ]] || { echo " skipped"; return 0; }
|
||||
fi
|
||||
|
||||
# access keys: deactivate (reversible) then delete
|
||||
for k in $(aws_ iam list-access-keys --user-name "$u" --query 'AccessKeyMetadata[].AccessKeyId' --output text); do
|
||||
aws_ iam update-access-key --user-name "$u" --access-key-id "$k" --status Inactive
|
||||
aws_ iam delete-access-key --user-name "$u" --access-key-id "$k"
|
||||
done
|
||||
# detach managed policies
|
||||
for p in $(aws_ iam list-attached-user-policies --user-name "$u" --query 'AttachedPolicies[].PolicyArn' --output text); do
|
||||
aws_ iam detach-user-policy --user-name "$u" --policy-arn "$p"
|
||||
done
|
||||
# delete inline policies
|
||||
for ip in $(aws_ iam list-user-policies --user-name "$u" --query 'PolicyNames[]' --output text); do
|
||||
aws_ iam delete-user-policy --user-name "$u" --policy-name "$ip"
|
||||
done
|
||||
# remove from groups
|
||||
for g in $(aws_ iam list-groups-for-user --user-name "$u" --query 'Groups[].GroupName' --output text); do
|
||||
aws_ iam remove-user-from-group --user-name "$u" --group-name "$g"
|
||||
done
|
||||
# MFA devices
|
||||
for m in $(aws_ iam list-mfa-devices --user-name "$u" --query 'MFADevices[].SerialNumber' --output text); do
|
||||
aws_ iam deactivate-mfa-device --user-name "$u" --serial-number "$m"
|
||||
done
|
||||
# login profile (console password)
|
||||
aws_ iam delete-login-profile --user-name "$u" 2>/dev/null || true
|
||||
# signing certs
|
||||
for c in $(aws_ iam list-signing-certificates --user-name "$u" --query 'Certificates[].CertificateId' --output text 2>/dev/null); do
|
||||
aws_ iam delete-signing-certificate --user-name "$u" --certificate-id "$c"
|
||||
done
|
||||
# SSH public keys (CodeCommit)
|
||||
for s in $(aws_ iam list-ssh-public-keys --user-name "$u" --query 'SSHPublicKeys[].SSHPublicKeyId' --output text 2>/dev/null); do
|
||||
aws_ iam delete-ssh-public-key --user-name "$u" --ssh-public-key-id "$s"
|
||||
done
|
||||
# service-specific credentials
|
||||
for sc in $(aws_ iam list-service-specific-credentials --user-name "$u" --query 'ServiceSpecificCredentials[].ServiceSpecificCredentialId' --output text 2>/dev/null); do
|
||||
aws_ iam delete-service-specific-credential --user-name "$u" --service-specific-credential-id "$sc"
|
||||
done
|
||||
|
||||
aws_ iam delete-user --user-name "$u"
|
||||
echo " $u: deleted"
|
||||
}
|
||||
|
||||
rc=0
|
||||
for u in "${USERS[@]}"; do delete_user "$u" || rc=1; done
|
||||
exit $rc
|
||||
61
scripts/resource-usage-probe.sh
Executable file
61
scripts/resource-usage-probe.sh
Executable file
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# resource-usage-probe.sh — is this resource actually used?
|
||||
#
|
||||
# Run BEFORE acting on an "encrypt / migrate / right-size / encrypt-with-downtime"
|
||||
# finding. Idle resources should usually be retired (cheaper, no downtime) instead
|
||||
# of hardened in place. This is what flipped audit H-19 from "encrypt database-1
|
||||
# with a downtime window" to "snapshot + delete" — it had 0 connections in 60 days.
|
||||
# See feedback memory `feedback_cfn_decommission_and_remediation`.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/resource-usage-probe.sh [--profile NAME] rds <db-instance-id>
|
||||
# scripts/resource-usage-probe.sh [--profile NAME] lambda <function-name>
|
||||
# scripts/resource-usage-probe.sh [--profile NAME] ddb <table-name>
|
||||
# scripts/resource-usage-probe.sh [--profile NAME] ebs <volume-id>
|
||||
#
|
||||
set -euo pipefail
|
||||
PROFILE_ARG=(); ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--profile) PROFILE_ARG=(--profile "$2"); shift 2 ;;
|
||||
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
[[ ${#ARGS[@]} -lt 2 ]] && { echo "usage: $0 [--profile NAME] {rds|lambda|ddb|ebs} <id>" >&2; exit 2; }
|
||||
KIND="${ARGS[0]}"; ID="${ARGS[1]}"; R="us-east-1"
|
||||
aws_() { aws "${PROFILE_ARG[@]}" "$@"; }
|
||||
# UTC window helpers (no Date.now equivalent needed; python gives tz-aware UTC)
|
||||
since() { python3 -c "import datetime;print((datetime.datetime.now(datetime.UTC)-datetime.timedelta(days=$1)).strftime('%Y-%m-%dT%H:%M:%SZ'))"; }
|
||||
now() { python3 -c "import datetime;print(datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ'))"; }
|
||||
maxstat() { aws_ cloudwatch get-metric-statistics --namespace "$1" --metric-name "$2" \
|
||||
--dimensions Name="$3",Value="$4" --start-time "$(since "$5")" --end-time "$(now)" \
|
||||
--period $(( $5 * 86400 )) --statistics Maximum Sum --region "$R" \
|
||||
--query 'Datapoints[0].{Max:Maximum,Sum:Sum}' --output text 2>/dev/null; }
|
||||
|
||||
echo "== $KIND: $ID =="
|
||||
case "$KIND" in
|
||||
rds)
|
||||
aws_ rds describe-db-instances --db-instance-identifier "$ID" --region "$R" \
|
||||
--query 'DBInstances[0].{Engine:Engine,Class:DBInstanceClass,Enc:StorageEncrypted,MultiAZ:MultiAZ,SG:VpcSecurityGroups[].VpcSecurityGroupId}' --output table
|
||||
echo "connections (Max/Sum over 60d): $(maxstat AWS/RDS DatabaseConnections DBInstanceIdentifier "$ID" 60)"
|
||||
echo "tags: $(aws_ rds list-tags-for-resource --resource-name "arn:aws:rds:$R:$(aws_ sts get-caller-identity --query Account --output text):db:$ID" --query 'TagList' --output text 2>/dev/null || echo none)" ;;
|
||||
lambda)
|
||||
echo "invocations (Max/Sum 90d): $(maxstat AWS/Lambda Invocations FunctionName "$ID" 90)"
|
||||
echo "errors (Max/Sum 90d): $(maxstat AWS/Lambda Errors FunctionName "$ID" 90)"
|
||||
aws_ lambda get-function-configuration --function-name "$ID" --region "$R" --query '{LastModified:LastModified,Runtime:Runtime}' --output table 2>/dev/null || true ;;
|
||||
ddb)
|
||||
aws_ dynamodb describe-table --table-name "$ID" --region "$R" --query 'Table.{Items:ItemCount,Bytes:TableSizeBytes,Billing:BillingModeSummary.BillingMode}' --output table
|
||||
echo "consumed write (Max/Sum 30d): $(maxstat AWS/DynamoDB ConsumedWriteCapacityUnits TableName "$ID" 30)"
|
||||
echo "consumed read (Max/Sum 30d): $(maxstat AWS/DynamoDB ConsumedReadCapacityUnits TableName "$ID" 30)"
|
||||
echo "PITR: $(aws_ dynamodb describe-continuous-backups --table-name "$ID" --region "$R" --query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus' --output text 2>/dev/null)" ;;
|
||||
ebs)
|
||||
aws_ ec2 describe-volumes --volume-ids "$ID" --region "$R" \
|
||||
--query 'Volumes[0].{Size:Size,State:State,Enc:Encrypted,Attached:Attachments[0].InstanceId,AttachState:Attachments[0].State}' --output table ;;
|
||||
*) echo "unknown kind: $KIND (use rds|lambda|ddb|ebs)" >&2; exit 2 ;;
|
||||
esac
|
||||
echo
|
||||
echo "VERDICT GUIDE: near-zero connections/invocations/consumed-capacity + no recent attachment => IDLE."
|
||||
echo " IDLE -> retire (final backup, then delete) — cheaper, no downtime than encrypt/migrate-in-place."
|
||||
echo " ACTIVE-> harden in place per the finding."
|
||||
Loading…
Add table
Reference in a new issue