* feat(secrev): doc-drift Plane-1 Tier-1 checker (UNGATED)
Third Plane-1 checker on the Phase-0 shared substrate, mirroring
compliance-drift.sh / dependency-cve.sh conventions verbatim (set -euo pipefail,
sourced substrate, --canary/--dry-run/--no-api/--refresh/--targets, mode-600
reports under $REPORT_ROOT/doc-drift/<UTC-date>/, ALARM-only, finding.schema
spirit JSON, exit 0/2/3, dotgit->.git fixture trick).
Detects documentation drift deterministically (design §4 doc-drift row):
- readme-omits-component: README omits an existing major component in the tree
(top-level service dir, SAM/CDK stack, Lambda handler dir, openapi/docs spec)
- readme-stale-vs-code: README last-touch far older than newest code commit
(two-factor: >=DOC_DRIFT_STALE_DAYS AND >=DOC_DRIFT_STALE_COMMITS)
A repo with NO README is SKIPPED (compliance-drift owns readme-present; no
double-flag). Future Gemini large-context judge (§4) is an inert stub (maybe_judge),
off in canary/dry-run/offline.
Planted-drift fixture corpus + EXPECTED_DRIFT_COUNT=4, canary-asserted (exit 3 on
miss). shellcheck -x clean (only accepted SC1091 source-line info).
Does NOT touch checker_coordinator.sh, requirements.txt, or aws-posture.
Wiring/systemd is gated (PROVISIONING footer). Design refs §4, §7 Phase 3.
* feat(secrev): Phase-3 IAM artifacts for cross-review (aws-posture gated)
Authored FILES (not applied to AWS — provisioning gated behind the mandatory
GPT-4.1 IAM cross-review + Adam, design §7 B3) for the aws-posture checker's
read-only AWS identity. Decision D5: box stays read-only, auths via IAM Roles
Anywhere short-lived leaf certs from a new internal step-ca; NO long-lived AWS key.
- aws-posture-readonly-policy.json least-privilege read-only (ce:Get*,
cloudwatch:GetMetric*/DescribeAlarms, ec2/elb/rds:Describe*, lambda list +
GetFunctionConfiguration, s3:ListAllMyBuckets/GetBucketLocation). No write,
no iam:* mutation, no s3:GetObject/secrets/kms/logs data reads, no wildcard
actions. Resource:* only where AWS has no resource-level support.
- aws-posture-readonly-policy.rationale.md per-statement least-privilege rationale.
- aws-posture-trust-policy.json pins Roles Anywhere principal + leaf subject CN +
issuer CN + trust-anchor SourceArn (three conditions, all required).
- roles-anywhere-config.json trust anchor (pins step-ca root) + profile (1h session).
- step-ca-config-sketch.md internal CA config + systemd-timer leaf auto-renewal.
- CROSS-REVIEW-PACKET.md end-to-end trust model, blast radius, EXERCISED rollback,
reviewer scrutiny list.
Does NOT build aws-posture.sh, touch checker_coordinator.sh, or requirements.txt.
* fix(secrev): apply IAM cross-review FIXes
GPT-4.1 IAM cross-review 2026-06-18: APPROVE, no BLOCKs. Applied FIXes:
- trust policy: add aws:SourceAccount=328440206208 (confused-deputy guard)
alongside the existing aws:SourceArn trust-anchor pin
- readonly policy: remove ec2:DescribeImages (data minimization — AMIs are
not an idle-spend signal)
- aws:RequestedRegion NIT: deliberately SKIPPED — ce:* and s3:ListAllMyBuckets
are global-endpoint services a blanket region condition could DENY; rationale
recorded in aws-posture-readonly-policy.rationale.md
- rationale.md + CROSS-REVIEW-PACKET.md: record APPROVE + FIXes + NIT answers
(snapshots=account-owned idle signal; s3 list=names-only; no logs:* needed)
* feat(secrev): aws-posture checker (Tier-2, provisioning-gated)
Read-only Tier-2 idle/anomalous-spend + idle-resource posture checker for the
R720 agent-team (design D5 / §4 / §6.3 / §7 Phase 3). Mirrors the Tier-1 checker
conventions verbatim (flags --canary/--dry-run/--no-api/--targets, mode-600
report under $REPORT_ROOT/aws-posture/<date>/, ALARM-only, finding.schema.json
spirit, exit 0/2/3, shared substrate redact/post_slack_alarm).
Detectors (complement GuardDuty/SecurityHub/Config, do not replace):
- anomalous Cost Explorer deltas (ce get-anomalies, $-impact threshold)
- stopped EC2 still paying for attached EBS
- unattached EBS volumes
- unassociated Elastic IPs
- idle NAT gateways (≈0 bytes out)
- idle load balancers (0 healthy targets)
- idle RDS (0 connections over window)
Live AWS calls are PROVISIONING-GATED: they run ONLY when Roles Anywhere creds
are available (STS identity probe) AND not --no-api/--canary. With no creds or
--no-api/--canary the checker SKIPS live calls and notes them — NEVER alarms on
missing data (memory feedback_cloudwatch_alarms). Roles Anywhere/step-ca are not
stood up (IAM cross-review PASSED 2026-06-18; see security-review/iam/).
Offline canary: fixtures of mocked AWS responses (cost/describe-* JSON) under
fixtures/aws-posture/ + EXPECTED_FINDING_COUNT=7, asserted fully offline (no aws,
no network). Identical detector code runs online and offline. shellcheck-clean
(only accepted SC1091), chmod +x.
* fix(secrev): doc-drift fixture py ruff-clean (root CI runs check + format --check)
The repo-root CI lint runs both 'ruff check .' and 'ruff format --check .' over
all fixtures. Fixed E701 one-liners and ruff-formatted the sample-service .py
files (handlers/*, feature_*.py). Fixture content is irrelevant to doc-drift
(keys on file/dir presence + git staleness).
11 KiB
Cross-review packet — R720 aws-posture IAM (step-ca → Roles Anywhere → read-only AWS role)
GPT-4.1 cross-review 2026-06-18: APPROVE, no BLOCKs; FIXes applied (
aws:SourceAccountadded to the trust policy;ec2:DescribeImagesremoved from the permission policy). NIT answers recorded inaws-posture-readonly-policy.rationale.md: snapshots = account-owned idle-spend signal (kept);s3:ListAllMyBuckets= names-only inventory, no object data (kept); nologs:*needed;aws:RequestedRegiondeliberately SKIPPED (global-endpointce:*/s3:ListAllMyBucketscould be DENYed by a blanket region pin).
Audience: the mandatory GPT-4.1 IAM cross-review + Adam. Status: these are AUTHORED FILES, nothing is applied to AWS. The review has now PASSED (APPROVE, no BLOCKs), which unblocks building aws-posture (done in this Phase-3 change set, PROVISIONING-GATED — the checker makes no AWS call until step-ca + Roles Anywhere are stood up). Approving this packet unblocks provisioning; it does not itself change AWS.
Account: 328440206208 · Region: us-east-1 · Box: the always-on R720 secrev VM (single-user, unattended, currently holds a long-lived read-only GitHub PAT).
Why this exists
aws-posture (design D5 / §4) is a weekly idle/anomalous-spend watch (the design flags ≈$330/mo of waste). It must read Cost Explorer + utilization metrics + an idle-resource inventory from the account. The decision (D5): the box stays read-only, and it authenticates to AWS via IAM Roles Anywhere using short-lived leaf certs issued by a new internal step-ca — NO long-lived AWS access key on the box. The short-lived self-expiring leaf is strictly stronger than the box's existing long-lived PAT.
Files in this packet
| File | What it is |
|---|---|
aws-posture-readonly-policy.json |
The least-privilege permission policy (valid IAM JSON, applyable as-is). |
aws-posture-readonly-policy.rationale.md |
Statement-by-statement least-privilege rationale (IAM JSON can't hold comments). |
aws-posture-trust-policy.json |
The role's trust policy — who may assume it (Roles Anywhere + pinned cert CN/issuer + pinned trust-anchor ARN). |
roles-anywhere-config.json |
The Roles Anywhere trust anchor + profile config (pins the step-ca root; 1h session cap). |
step-ca-config-sketch.md |
The internal CA config + the systemd-timer auto-renewal approach. |
CROSS-REVIEW-PACKET.md |
This document. |
Trust model, end to end
step-ca ROOT cert (CN="Sea Haven Internal CA - R720 Roles Anywhere")
│ pinned as the Roles Anywhere trust anchor (roles-anywhere-config.json)
▼
step-ca issues a SHORT-LIVED leaf (CN="r720-aws-posture", ~24h, auto-renewed hourly by systemd timer)
│ stored mode-600 on the box; private key never leaves the box; no AWS key on disk
▼
box calls AWS via aws_signing_helper credential-process, signing with the leaf
▼
IAM Roles Anywhere trust anchor (r720-aws-posture-step-ca)
│ validates: leaf chains to the pinned root? yes -> emit session tags
│ aws:PrincipalTag/x509Subject/CN = "r720-aws-posture"
│ aws:PrincipalTag/x509Issuer/CN = "Sea Haven Internal CA - R720 Roles Anywhere"
▼
Roles Anywhere profile (r720-aws-posture-readonly, durationSeconds=3600)
│ binds ONLY the one role
▼
sts:AssumeRole on role/r720-aws-posture-readonly
│ trust policy (aws-posture-trust-policy.json) requires ALL of:
│ (1) Principal = rolesanywhere.amazonaws.com (came via Roles Anywhere)
│ (2) aws:SourceArn = THIS trust anchor (not some other anchor)
│ (2b) aws:SourceAccount = 328440206208 (confused-deputy guard, added in cross-review)
│ (3) x509Subject/CN = "r720-aws-posture" AND x509Issuer/CN = the internal CA
▼
1-hour STS session, permissions = aws-posture-readonly-policy.json (read-only cost + idle inventory)
▼
read-only AWS APIs: ce:Get*, cloudwatch:GetMetric*/DescribeAlarms, ec2/elb/rds:Describe*,
lambda:List/GetFunctionConfiguration, s3:ListAllMyBuckets/GetBucketLocation
Three independent conditions must ALL hold to assume the role: via Roles Anywhere, from THIS
anchor (in THIS account, via the aws:SourceAccount guard added in cross-review), presenting a
leaf with the pinned subject CN + issuer CN. Any one missing → AssumeRole denied.
Least-privilege rationale (summary; full table in the rationale .md)
- Read-only only. No write/modify/delete verb in any service. No
iam:*mutation, no privilege-escalation path, nostsonward-chaining. Resource: "*"only where AWS gives no choice. Cost Explorer, the CloudWatch metric-data calls, and the EC2/ELB/RDSDescribe*list operations do not support resource-level ARNs; least-privilege there is the action allow-list, not the resource. There are no wildcard actions (ce:*,ec2:*) anywhere — every action is an explicit read verb.- No data-plane reads. Deliberately excludes
s3:GetObject,secretsmanager:GetSecretValue,ssm:GetParameter*,kms:Decrypt,logs:GetLogEvents. The role enumerates and prices the account; it cannot read application data, secrets, or logs. - Tighter than the AWS-managed
ReadOnlyAccess/ViewOnlyAccess(those include object reads, table reads, etc.) — this is the small cost+inventory subset aws-posture actually queries.
Blast radius if the leaf (or its private key) is compromised
- Ceiling = read-only enumeration + pricing of account 328440206208 for ≤1 hour per session
(STS
durationSeconds=3600), and only while a valid unexpired leaf exists (~24h leaf life). - Cannot: read S3 object data, read secrets/SSM params, read CloudWatch logs, modify or delete any resource, touch IAM, assume any other role, or act in any other account/region scope beyond what read-only describe calls expose.
- Containment levers, fastest first:
- Disable the Roles Anywhere profile or trust anchor (
enabled:false) → immediately stops all new credential vending, regardless of leaf validity. (seconds) - Detach/empty the role's permission policy → any still-live session loses all access at the next AWS authz check. (seconds)
- Revoke at the CA / rotate the leaf → step-ca stops renewing; the leaf self-expires within its ≤24h window even with no action.
- Disable the Roles Anywhere profile or trust anchor (
- The self-expiring leaf means even a "do nothing" outcome bounds exposure to the leaf lifetime — unlike the box's current long-lived PAT, which would persist until manually rotated.
Rollback (EXERCISED, not just written — design §7 "exercised, not merely written")
Teardown order is the reverse of provisioning; each step is independently sufficient to cut access. Tested via a simulated teardown/re-provision against throwaway names (see "Exercise" below) before this packet is accepted.
ACC=328440206208 ; REG=us-east-1
TA_ID=REPLACE_TRUST_ANCHOR_ID ; PROF_ID=REPLACE_PROFILE_ID
ROLE=r720-aws-posture-readonly ; POLICY=r720-aws-posture-readonly
# 1) Stop credential vending FIRST (fastest cut): disable then delete the profile + trust anchor.
aws rolesanywhere disable-profile --profile-id "$PROF_ID" --region "$REG"
aws rolesanywhere disable-trust-anchor --trust-anchor-id "$TA_ID" --region "$REG"
aws rolesanywhere delete-profile --profile-id "$PROF_ID" --region "$REG"
aws rolesanywhere delete-trust-anchor --trust-anchor-id "$TA_ID" --region "$REG"
# 2) Remove the role + its permission policy.
POLICY_ARN="arn:aws:iam::$ACC:policy/$POLICY"
aws iam detach-role-policy --role-name "$ROLE" --policy-arn "$POLICY_ARN"
aws iam delete-role --role-name "$ROLE"
aws iam delete-policy --policy-arn "$POLICY_ARN"
# 3) Remove the internal CA + the leaf on the box (no AWS state involved).
sudo systemctl disable --now aws-posture-cert-renew.timer step-ca.service
sudo rm -f /etc/aws-posture/leaf.crt /etc/aws-posture/leaf.key
sudo rm -rf /etc/step-ca # destroys root/intermediate/keys -> no further leaves issuable
# (optional) remove the [profile r720-aws-posture] block from ~/.aws/config
Re-provision = re-run step ca init (step-ca-config-sketch.md) → re-create role + policy +
trust anchor + profile → drop in the new trust-anchor/profile ARNs. A revert point (VM snapshot
per feedback_ec2_replacement_snapshot) is taken before provisioning so the whole change is one
snapshot-restore away from gone.
Exercise log (to be completed before acceptance)
Run the provision → assume-once (confirm read-only works, confirm a write is denied) → run the rollback above against throwaway-suffixed names → confirm AssumeRole now fails and the CA is gone. Paste the transcript here. Until this is filled in, the rollback is "written, not exercised" and the phase is NOT accepted (design §7).
Specific things for the cross-reviewer to scrutinize (with resolutions)
- Trust-policy condition completeness. Are
aws:PrincipalTag/x509Subject/CN+aws:PrincipalTag/x509Issuer/CN+ArnEquals aws:SourceArn(the trust anchor) sufficient to prevent any other cert (or another trust anchor in the account) from assuming the role? Is there a confused-deputy gap I should also pin (e.g. should I addaws:SourceAccount)? → RESOLVED (FIX applied): addedaws:SourceAccount = 328440206208to the StringEquals condition. The trust now pins anchor (SourceArn) and account (SourceAccount) plus the cert CN/issuer — closing the confused-deputy gap the reviewer raised. Resource: "*"statements. Confirm each is an API that genuinely has no resource-level support, and that no statement could be tightened with a condition (e.g.aws:RequestedRegion= us-east-1) without breaking the checker. → RESOLVED (NIT, SKIPPED with rationale): everyResource:"*"statement is an API family without resource-level ARNs (ce, the cloudwatch metric-data calls, ec2/elb/rdsDescribe*).aws:RequestedRegionis NOT applied —ce:*ands3:ListAllMyBucketsare global-endpoint services that a blanket region condition could DENY. Full reasoning inaws-posture-readonly-policy.rationale.md("Cross-review NIT answers").- Action allow-list. Anything in here that is NOT needed for idle/anomalous-spend (i.e.
over-grant), or any read verb that leaks data we don't want (the intent is pricing + inventory,
no object/secret/log data).
→ RESOLVED (FIX applied): removed
ec2:DescribeImages(AMIs are not an idle-spend signal).ec2:DescribeSnapshotskept (orphan-snapshot waste, account-owned metadata only);s3:ListAllMyBucketskept (bucket names only, nos3:GetObject); nologs:*granted. - Session duration vs leaf lifetime. 1h STS session + ~24h leaf — acceptable blast window? → Accepted as-is (no change requested).
- Rollback ordering. Is disabling the profile/anchor first the correct fastest-cut order, and does step 2/3 leave any orphaned grant? → Accepted as-is (no change requested).
Process note
Per global instructions this IAM change ALSO requires the GPT-4.1 cross-family review run via
python3 ~/Documents/repositories/orchestrator/run.py "<diff/describe>" (it is an IAM
role/policy + trust-anchor change). This packet is the input to that review; aws-posture is not
built until the review is recorded.