Compare commits

...

14 commits

Author SHA1 Message Date
dependabot[bot]
1d29183743
Merge 34da6b4082 into a9d9f12a40 2026-06-05 17:04:12 +00:00
dependabot[bot]
34da6b4082
Bump @types/node from 22.19.19 to 25.9.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.19.19 to 25.9.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 17:04:08 +00:00
Adam Moussa
a9d9f12a40
fix(deps): bump aws-cdk-lib pin to 2.257.0 (#15)
Some checks are pending
Deploy / deploy (push) Waiting to run
2026-06-05 13:01:36 -04:00
Adam Moussa
05b0f95c4f
Add dependency-review caller workflow (#14)
* Add dependency-review caller workflow

Add a pull_request-triggered caller that invokes the org-level
callable-dependency-review workflow to scan dependency changes and
fail on high-severity advisories.

* chore: retrigger checks

* chore: retrigger dep review (post-fix)
2026-06-05 12:26:57 -04:00
Adam Moussa
993702f421
Replace aws/sns key with CMK on alarm topics (#13)
Some checks failed
Deploy / deploy (push) Has been cancelled
Audit L-14, plus a latent Day-2 bug: seahaven-cis-alarms was
encrypted with the AWS-managed alias/aws/sns key, whose policy cannot
grant cloudwatch.amazonaws.com - CloudWatch alarms silently fail to
publish to topics it encrypts. All 15 CIS alarms would have fired
into the void.

New customer-managed key (rotation on) grants CloudWatch
GenerateDataKey*/Decrypt/DescribeKey scoped by SourceAccount. The
unmanaged site-alerts topic now uses the same key (set via CLI).

Cross-reviewed: no BLOCKs. Verified: forced ALARM on the payroll DLQ
alarm published successfully through the encrypted site-alerts.
2026-06-03 15:33:52 -04:00
Adam Moussa
2289dcb0c9
Add Bedrock invocation logging destinations (#12)
Some checks are pending
Deploy / deploy (push) Waiting to run
Audit finding H-20: no audit trail of model I/O for seahaven-alex,
which returns payments, invoices, WO/PO, and HR/SA8000 data.

S3 bucket (Glacier at 90d, expire 365d) + CloudWatch log group (90d)
+ delivery role assumable only by bedrock.amazonaws.com scoped by
SourceAccount/SourceArn. The account-level logging configuration has
no CloudFormation resource type, so it is applied via CLI post-deploy
(documented in the construct header) - same pattern as the Config
recorder (INFRA-17).

Cross-reviewed: no BLOCKs. Verified live: converse invocation logged
to /aws/bedrock/model-invocations.
2026-06-03 15:17:39 -04:00
Adam Moussa
14593440cf
Add cfn-stack-decommission + resource-usage-probe ops scripts (#11)
Some checks are pending
Deploy / deploy (push) Waiting to run
cfn-stack-decommission.sh: report-by-default stack retirement; pre-flight
predicts DeletionPolicy:Retain orphans + consumed-export blocks before delete
(distilled from the LedgerFlow decommission). --execute to act.

resource-usage-probe.sh: is-it-used probe (RDS connections/Lambda invocations/
DDB capacity/EBS attachment) to choose retire-vs-harden before acting on an
encrypt/migrate finding (the database-1 H-19 lesson).
2026-06-03 13:25:44 -04:00
Adam Moussa
0dd8d2a7af
Docs + tooling: README phase-2 backup, iam-user-delete script (#10)
README: document AWS Backup phase-2 (phase2-offsite-everything selection),
remove retired database-1 from phase-1 scope (audit H-19), update roadmap +
verify smoke-test to a live resource.

scripts/iam-user-delete.sh: reusable full IAM user teardown (keys, policies,
groups, MFA, login profile, certs, SSH keys, service creds, then user) with
--profile/--yes and a guard against deleting the caller's own identity. Built
from the Day 4 audit IAM cleanup.
2026-06-03 13:15:18 -04:00
Adam Moussa
1d6668090c
Remove retired database-1 + ledgerflow-pos from backup selections (audit Day 4) (#9)
Some checks are pending
Deploy / deploy (push) Waiting to run
database-1 (audit H-19) and the LedgerFlow stack (incl. ledgerflow-pos) were
decommissioned 2026-06-03. Drop database-1 from the critical-data selection and
ledgerflow-pos from phase2-offsite-everything so daily jobs don't target missing
resources. database-1's final recovery point is retained encrypted in the
seahaven-offsite vault (7yr); ledgerflow-pos has a final on-demand DynamoDB
backup. Deployed before merge (seahaven-backup UPDATE_COMPLETE).
2026-06-03 11:45:37 -04:00
Adam Moussa
f2a0cc40d6
Expand AWS Backup to remaining DDB + EBS (audit Day 4 phase-2) (#8)
Add a second BackupSelection 'phase2-offsite-everything' on the existing
seahaven-critical-daily plan covering the 15 remaining DynamoDB tables and
all 9 in-use EBS volumes, with the same daily backup + cross-region copy to
the GOVERNANCE-locked seahaven-offsite vault ('offsite for everything').

Reuses seahaven-backup-service-role (AWSBackupServiceRolePolicyForBackup
already grants DDB/RDS/EBS) - no IAM change. Explicit-ARN (not tag-based) to
avoid drifting the standalone file-share volumes and stack-owned tables, same
as phase-1. The 4 deprecated ledgerflow delete-targets are excluded.

Cross-reviewed (no BLOCK). Follow-up: migrate EBS to tag-based selection with
tags codified in owning stacks for resilience to volume replacement.

Deployed to seahaven-backup before merge; selection verified live (24 resources).
2026-06-03 11:18:53 -04:00
Adam Moussa
60e8b0e9ed
Add shared CloudFront WAF WebACL (audit Day 3: M-17) (#7)
Some checks are pending
Deploy / deploy (push) Waiting to run
seahaven-app-waf (CLOUDFRONT scope, us-east-1): AWS managed Common + Known Bad
Inputs rule groups + per-IP rate limit (2000/5min). ARN published to SSM
/seahaven/waf/app-web-acl-arn for app stacks (meal-order/orders) to consume.
seahaven.com already has its own WAF; ledgerflow is being decommissioned
(INFRA-26); proposal-system-web skipped (not live).
2026-06-02 16:42:24 -04:00
Adam Moussa
3ba90ddc40
Add monitoring + logging layer (audit Day 2: H-1/H-14/M-13) (#6)
Some checks are pending
Deploy / deploy (push) Waiting to run
- H-1: 15 CIS Section 4 metric filters (4.1-4.15) on the CloudTrail log group,
  each alarming to a new SSE SNS topic seahaven-cis-alarms (email to adam).
  ALARM-only actions per Sea Haven preference. 4.16 = Security Hub (Day 1).
- H-14: VPC flow logs (ALL traffic) on all 5 VPCs → hardened S3 bucket. Delivery
  bucket policy cross-reviewed; kept the AWS-required s3:x-amz-acl condition +
  logs:*:* source-ARN (cross-reviewer wrongly flagged these; verified against
  AWS flow-logs-s3-permissions docs), dropped the unneeded s3:ListBucket.
- M-13: SES configuration set seahaven-email-events capturing bounce/complaint/
  reject to CloudWatch for reputation visibility.

L-4 (log retention) and L-5 (alarm action) applied via CLI, documented in README.
2026-06-02 15:16:24 -04:00
Adam Moussa
38d4a5753a
Account detective layer + budget (audit Day 1) (#5)
Some checks are pending
Deploy / deploy (push) Waiting to run
* Add account detective layer + budget (audit Day 1: H-2/H-3/H-4/M-5/M-10)

Adds to the seahaven-account-baseline stack:
- AWS Config recorder (all + global resources) + delivery channel + role +
  hardened delivery bucket (H-2, CIS 3.3/3.5). Recorder role IAM cross-reviewed.
- GuardDuty detector, us-east-1 (H-3)
- Security Hub with AWS FSBP v1.0.0 + CIS v3.0.0 standards, depends on Config (H-4)
- IAM Access Analyzer, account scope (M-5)
- Monthly cost budget $1,200 with 80/100% actual + 100% forecast alerts to
  adam@seahavenind.com (M-10)

Scope us-east-1 only (all workloads here); multi-region is a follow-up.
The CLI-applied governance toggles (M-6/M-3/M-7/L-8/M-11) are documented
separately in the README runbook.

* Document Day 1 detective layer + CLI governance toggles in README

* Move Config recorder+channel to CLI (L1 stabilization deadlock)

The L1 AWS::Config::ConfigurationRecorder hangs the stack: it never reaches
CREATE_COMPLETE until recording is active (needs a delivery channel), and the
delivery channel cannot be created until the recorder completes — a deadlock
that hung the deploy ~27 min before manual cancel (2026-06-01).

Keep the cross-reviewed recorder role + delivery bucket in IaC; create the
recorder, delivery channel, and start recording via CLI (documented in README).
Security Hub no longer takes a CFN dependency on the recorder; CIS/FSBP controls
evaluate once Config is recording. Verified live: recording=true, SUCCESS.
2026-06-01 17:56:12 -04:00
Adam Moussa
64ef25dc5b
Add AWS Backup with offsite vault (audit C-7) (#3)
Some checks failed
Deploy / deploy (push) Has been cancelled
* Add AWS Backup with offsite vault (audit C-7)

The account had zero AWS Backup vaults/plans, so 22 of 23 data stores
had no immutable, cross-region recovery path (audit finding C-7). One
ransomware event or rogue delete would erase primary plus same-region
snapshots/PITR.

Phase 1 ("critical data first") protects the seven highest-risk stores
with no offsite leg today (2 RDS, 2 DynamoDB, 3 S3) via a daily plan in
a new us-east-1 vault, copied cross-region into a governance-locked
us-west-2 vault. Governance (not compliance) mode first so the plan can
be validated before committing to irreversible immutability.

The backup service role is backup-only (no restore policies) to stay
least-privilege; restores get a separate audited path later. Resources
are selected by explicit ARN to avoid drifting the stacks that own them.

Deploys via the shared cdk deploy --all alongside the C-1 CloudTrail
stack. See the README pre-deploy gates (S3 versioning, database-1
unencrypted copy smoke-test, DynamoDB PITR) before the first run.

* Grant AWS Backup service use of vault CMKs

The L2 BackupVault does not grant the backup service principal use of a
customer-managed key; the synthesized key policy only delegated to
account IAM. Cross-region copy of encrypted RDS/EBS recovery points uses
KMS grants on the destination key, so without an explicit grant those
copy jobs fail — and silently, since the account has no CloudTrail yet.

Add backup.amazonaws.com crypto + CreateGrant statements to both vault
keys, scoped by aws:SourceAccount (cross-review BLOCK 2; mirrors the
discipline used on the C-1 CloudTrail key). Same class of bug the C-1
cross-review caught on the CloudTrail CMK.
2026-05-29 18:06:17 -04:00
19 changed files with 1739 additions and 37 deletions

View file

@ -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:

View 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
View file

@ -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
```

View file

@ -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);

View file

@ -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");

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -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"
},

View file

@ -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"
}
}

View 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
View 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
View 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."