From a9bccf33d0256f1f50a68f1fc81d14825bee12c4 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Thu, 18 Jun 2026 16:13:11 -0400 Subject: [PATCH] feat(secrev): aws-posture checker (Tier-2, provisioning-gated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, 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. --- security-review/checkers/aws-posture.sh | 474 ++++++++++++++++++ .../aws-posture/EXPECTED_FINDING_COUNT | 1 + .../checkers/fixtures/aws-posture/README.md | 34 ++ .../fixtures/aws-posture/cost-anomalies.json | 32 ++ .../aws-posture/describe-addresses.json | 17 + .../aws-posture/describe-db-instances.json | 20 + .../aws-posture/describe-instances.json | 27 + .../aws-posture/describe-load-balancers.json | 18 + .../aws-posture/describe-nat-gateways.json | 19 + .../aws-posture/describe-volumes.json | 20 + security-review/iam/README.md | 11 +- 11 files changed, 670 insertions(+), 3 deletions(-) create mode 100755 security-review/checkers/aws-posture.sh create mode 100644 security-review/checkers/fixtures/aws-posture/EXPECTED_FINDING_COUNT create mode 100644 security-review/checkers/fixtures/aws-posture/README.md create mode 100644 security-review/checkers/fixtures/aws-posture/cost-anomalies.json create mode 100644 security-review/checkers/fixtures/aws-posture/describe-addresses.json create mode 100644 security-review/checkers/fixtures/aws-posture/describe-db-instances.json create mode 100644 security-review/checkers/fixtures/aws-posture/describe-instances.json create mode 100644 security-review/checkers/fixtures/aws-posture/describe-load-balancers.json create mode 100644 security-review/checkers/fixtures/aws-posture/describe-nat-gateways.json create mode 100644 security-review/checkers/fixtures/aws-posture/describe-volumes.json diff --git a/security-review/checkers/aws-posture.sh b/security-review/checkers/aws-posture.sh new file mode 100755 index 0000000..ccea471 --- /dev/null +++ b/security-review/checkers/aws-posture.sh @@ -0,0 +1,474 @@ +#!/usr/bin/env bash +# aws-posture.sh — Plane-1 / Tier-2 checker for the R720 agent-team. +# +# Design refs: docs/r720-agent-team-design.md D5 / §4 (Tier 2 roster: aws-posture — +# "Idle/anomalous spend (≈$330/mo flagged) + reasoning layer over baseline findings. Auths via +# Roles Anywhere (short-lived leaf certs, auto-rotated by step-ca). Complements existing +# GuardDuty/Security Hub/Config, does not replace them") and §6.3 / §7 Phase 3 ("doc-drift + +# step-ca/Roles Anywhere + aws-posture"). This is a Tier-2 checker built on the Phase-0 shared +# substrate (lib/sweep_substrate.sh); it mirrors compliance-drift.sh / dependency-cve.sh / +# doc-drift.sh conventions VERBATIM so the coordinator (§5) can drive all of them identically. +# +# WHAT IT DOES (read-only): +# Watches the Sea Haven AWS account (328440206208, us-east-1) for IDLE / ANOMALOUS SPEND and +# idle-resource posture: +# - anomalous Cost Explorer deltas (ce get-anomalies above a $ impact threshold) +# - stopped EC2 instances still paying for attached EBS +# - unattached ("available") EBS volumes +# - unassociated Elastic IPs +# - idle NAT gateways (≈0 bytes out over the window) +# - idle load balancers (0 healthy targets) +# - idle RDS instances (0 connections over the window) +# It COMPLEMENTS GuardDuty / Security Hub / Config (design §4) — it is a spend/idle-posture +# watch, NOT a threat detector, and does not replace them. +# +# AUTH / PROVISIONING GATE (design D5 / §6.3 / §7 B3): +# The LIVE read-only AWS calls require credentials vended via IAM Roles Anywhere using a +# short-lived step-ca leaf cert — this is **PROVISIONING-GATED and NOT available yet** (the IAM +# cross-review PASSED 2026-06-18, which unblocked BUILDING this checker, but step-ca + the trust +# anchor + the role are not stood up). See security-review/iam/ for the reviewed artifacts. +# Therefore the checker: +# (a) attempts read-only `aws` CLI calls ONLY when credentials are actually available +# (an STS identity probe succeeds) AND --no-api/--canary were not passed; +# (b) when there are NO credentials, OR --no-api, OR --canary: it SKIPS the live calls and +# NOTES them — it NEVER alarms on missing data (memory feedback_cloudwatch_alarms: no +# false alarms on no-data). This mirrors compliance-drift's API-skip pattern EXACTLY. +# +# REPORTING (matches secrev sweep conventions): +# - Writes a per-run JSON + text report under $REPORT_ROOT//, mode 600 (umask 077). +# - Slack ALARM-ONLY: a clean run (no confirmed waste) posts NOTHING (memory +# feedback_cloudwatch_alarms). Secret-shaped values are redacted from the Slack string. +# - Reuses the substrate's redact() + post_slack_alarm() verbatim. +# +# SUBSTRATE REUSE (lib/sweep_substrate.sh, sourced — bash dynamic scoping): +# redact, post_slack_alarm -> Slack delivery (reads SLACK_WEBHOOK_URL, REPORT_DIR, SWEEP_LOG) +# (aws-posture does NOT use discover_repos/mirror_repo — it scans an AWS account, not repos.) +# +# CANARY / DRY-RUN (offline, no network, no aws, no credentials): +# --canary runs the SAME detectors against a fixture of mocked AWS JSON responses +# (checkers/fixtures/aws-posture/) and asserts the known finding count against +# EXPECTED_FINDING_COUNT (exit 3 on mismatch). It makes ZERO `aws` calls and ZERO network +# calls. This is the anti-complacency floor (design §6.4) AND the routing dry-run (§7 Phase 3): +# --canary implies --dry-run + --no-api; with --dry-run the Slack alarm is composed + printed +# but NOT POSTed. +# +# SCOPE / SAFETY: +# Read-only. The reasoning ("Sonnet collectors + judge", design §4) is a LATER enhancement: a +# clearly-marked inert stub hook (maybe_judge) marks the future seam; it does NOTHING offline +# and NOTHING in this phase (the deterministic detectors are the whole checker here). Does NOT +# touch agent_team/ or agent-team/, is NOT wired into systemd, and stands NOTHING up in AWS — +# that is Phase-3/6 provisioning (gated). See the "PROVISIONING (NOT DONE HERE)" note at bottom. +# +# Exit: 0 = ran (whether or not it alarmed); 2 = setup/usage error; 3 = canary assertion FAILED. +set -euo pipefail +export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + +log() { echo "[aws-posture] $*" >&2; } +die() { echo "[aws-posture] FATAL: $*" >&2; exit 2; } + +# --- Shared substrate --------------------------------------------------------- +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SUBSTRATE="$HERE/../lib/sweep_substrate.sh" +[ -f "$SUBSTRATE" ] || die "shared substrate not found: $SUBSTRATE" +# shellcheck source=../lib/sweep_substrate.sh +. "$SUBSTRATE" + +# --- Config + defaults (env, all optional) ------------------------------------ +AWS_ACCOUNT="${AWS_ACCOUNT:-328440206208}" +AWS_REGION="${AWS_REGION:-us-east-1}" +REPORT_ROOT="${REPORT_ROOT:-$HOME/sweep-reports/aws-posture}" +# Cost-anomaly $ impact threshold: only anomalies whose TotalImpact >= this are flagged +# (a tiny anomaly is noise, not waste — no false alarm on a sub-threshold blip). +COST_ANOMALY_MIN_IMPACT="${COST_ANOMALY_MIN_IMPACT:-25}" +# A NAT gateway with bytes-out below this over the window is treated as idle. +NAT_IDLE_BYTES_MAX="${NAT_IDLE_BYTES_MAX:-1024}" +# An RDS instance with max connections at/below this over the window is treated as idle. +RDS_IDLE_CONN_MAX="${RDS_IDLE_CONN_MAX:-0}" + +DO_API=1 # --no-api: skip ALL live AWS calls (offline). Without creds this is forced. +DRY_RUN=0 # --dry-run: compose the Slack alarm but DO NOT post it (routing dry-run). +CANARY=0 # --canary: run the detectors against the mocked-AWS fixture + assert count. +TARGETS_OVERRIDE="" # --targets DIR: read mocked-AWS JSON from DIR instead of the live account + # (offline + deterministic; same file shape as the canary fixture). + +usage() { + cat >&2 </dev/null || die "jq is required" + +# --- Report dir (mode 600 reports; matches sweep conventions) ----------------- +umask 077 +UTC_DATE="$(date -u +%Y-%m-%d)" +UTC_STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +REPORT_DIR="$REPORT_ROOT/$UTC_DATE" +mkdir -p "$REPORT_DIR"; chmod 700 "$REPORT_ROOT" "$REPORT_DIR" 2>/dev/null || true +# shellcheck disable=SC2034 # read by the sourced substrate (post_slack_alarm) via dynamic scope +SWEEP_LOG="$REPORT_DIR/aws-posture.log" # name the substrate's post_slack_alarm() references +REPORT_JSON="$REPORT_DIR/aws-posture.json" +REPORT_TXT="$REPORT_DIR/aws-posture.txt" + +log "=== aws-posture $UTC_STAMP (canary=$CANARY dry_run=$DRY_RUN api=$DO_API account=$AWS_ACCOUNT region=$AWS_REGION) ===" + +# ------------------------------------------------------------------------------ +# FINDINGS (spirit of finding.schema.json so the coordinator can route like an agentic finding). +# category="other" (idle-spend is not one of the schema's security categories); +# status="confirmed" only for a deterministic idle/anomaly fact derived from a real response. +# A live call that could not be made (no creds / --no-api / transport failure) is a SKIP, never a +# finding (memory feedback_cloudwatch_alarms: no false alarms on missing data). +# ------------------------------------------------------------------------------ +declare -a FINDINGS=() +add_finding() { # id title severity check resource proof + local id="$1" title="$2" sev="$3" check="$4" resource="$5" proof="$6" + FINDINGS+=( "$(jq -n \ + --arg id "$id" --arg title "$title" --arg sev "$sev" \ + --arg check "$check" --arg resource "$resource" --arg proof "$proof" \ + '{account:env.AWS_ACCOUNT_FOR_FINDING, id:$id, title:$title, severity:$sev, category:"other", + check:$check, status:"confirmed", proof:{resource:$resource, outcome:$proof}}')" ) +} +export AWS_ACCOUNT_FOR_FINDING="$AWS_ACCOUNT" +declare -a SKIPPED_CHECKS=() # (check:reason) live calls skipped on missing data — reported, never alarmed +note_skip() { SKIPPED_CHECKS+=( "$1" ); } + +# Inert future seam (design §4 "Sonnet collectors + judge"): in LIVE mode an ambiguous idle +# candidate ("is this RDS truly idle or just low-traffic?") could be escalated to a reasoning +# judge. This phase keeps the deterministic detectors ONLY — the stub does nothing and is never +# reached offline / in canary / dry-run. +maybe_judge() { # candidate_json (no-op stub; Phase-3 intentionally inert) + return 0 +} + +# ============================================================================== +# DETECTORS — each consumes one AWS JSON response (live or fixture) and emits findings. +# Pure jq parsing; identical logic for the live `aws ... --output json` output and the canary +# fixture, so the canary genuinely exercises the production detectors. +# ============================================================================== + +# cost anomalies: ce get-anomalies. Flag anomalies whose Impact.TotalImpact >= threshold. +detect_cost_anomalies() { # json + local json="$1" + while IFS=$'\t' read -r aid svc impact; do + [ -n "$aid" ] || continue + add_finding "cost-anomaly-$aid" \ + "Cost anomaly: ${svc} (≈\$${impact} impact)" "high" "cost-anomaly" "$aid" \ + "ce get-anomalies TotalImpact \$${impact} >= threshold \$${COST_ANOMALY_MIN_IMPACT} (service: ${svc})" + done < <(echo "$json" | jq -r --argjson thr "$COST_ANOMALY_MIN_IMPACT" ' + (.Anomalies // [])[] + | select((.Impact.TotalImpact // 0) >= $thr) + | [.AnomalyId, (.DimensionValue // "unknown"), ((.Impact.TotalImpact // 0)|tostring)] + | @tsv') +} + +# stopped EC2 still paying for attached EBS: describe-instances, State.Name=="stopped" with EBS. +detect_stopped_instances() { # json + local json="$1" + while IFS=$'\t' read -r iid itype; do + [ -n "$iid" ] || continue + add_finding "stopped-ec2-$iid" \ + "Stopped EC2 instance still incurring EBS cost: $iid ($itype)" "medium" "stopped-instance" "$iid" \ + "ec2 describe-instances: State=stopped with attached EBS (storage bills while stopped)" + done < <(echo "$json" | jq -r ' + (.Reservations // [])[].Instances[] + | select((.State.Name // "") == "stopped") + | select(((.BlockDeviceMappings // []) | length) > 0) + | [.InstanceId, (.InstanceType // "?")] | @tsv') +} + +# unattached EBS: describe-volumes, State=="available". +detect_unattached_volumes() { # json + local json="$1" + while IFS=$'\t' read -r vid size vtype; do + [ -n "$vid" ] || continue + add_finding "unattached-ebs-$vid" \ + "Unattached EBS volume billing idle: $vid (${size}GiB $vtype)" "medium" "unattached-volume" "$vid" \ + "ec2 describe-volumes: State=available (no attachment) — billed but unused" + done < <(echo "$json" | jq -r ' + (.Volumes // [])[] + | select((.State // "") == "available") + | [.VolumeId, ((.Size // 0)|tostring), (.VolumeType // "?")] | @tsv') +} + +# unassociated EIP: describe-addresses, no AssociationId/InstanceId. +detect_unassociated_eips() { # json + local json="$1" + while IFS=$'\t' read -r alloc ip; do + [ -n "$alloc" ] || continue + add_finding "unassociated-eip-$alloc" \ + "Unassociated Elastic IP (hourly charge): $ip" "low" "unassociated-eip" "$alloc" \ + "ec2 describe-addresses: no AssociationId/InstanceId — idle EIPs are billed hourly" + done < <(echo "$json" | jq -r ' + (.Addresses // [])[] + | select((.AssociationId // "") == "" and (.InstanceId // "") == "") + | [(.AllocationId // .PublicIp), (.PublicIp // "?")] | @tsv') +} + +# idle NAT gateway: describe-nat-gateways, available + bytes-out below threshold. +# Live path injects the CloudWatch-derived bytes-out as _FixtureBytesOutLast14d (same key the +# canary fixture uses) before calling this — keeping detector logic identical online/offline. +detect_idle_nat() { # json + local json="$1" + while IFS=$'\t' read -r nid bytes; do + [ -n "$nid" ] || continue + add_finding "idle-nat-$nid" \ + "Idle NAT gateway (≈0 traffic, ~\$32/mo each): $nid" "medium" "idle-nat" "$nid" \ + "ec2 describe-nat-gateways: available with ${bytes} bytes out over window (<= ${NAT_IDLE_BYTES_MAX})" + done < <(echo "$json" | jq -r --argjson mx "$NAT_IDLE_BYTES_MAX" ' + (.NatGateways // [])[] + | select((.State // "") == "available") + | select((._FixtureBytesOutLast14d // 0) <= $mx) + | [.NatGatewayId, ((._FixtureBytesOutLast14d // 0)|tostring)] | @tsv') +} + +# idle ELB: describe-load-balancers, 0 healthy targets. +# Live path injects the per-LB healthy-target count as _FixtureHealthyTargetCount (derived from +# elbv2 describe-target-health) before calling this — same key the canary fixture uses. +detect_idle_elb() { # json + local json="$1" + while IFS=$'\t' read -r name; do + [ -n "$name" ] || continue + add_finding "idle-elb-$name" \ + "Idle load balancer (0 healthy targets, ~\$16/mo each): $name" "medium" "idle-elb" "$name" \ + "elbv2 describe-load-balancers + describe-target-health: 0 healthy targets" + done < <(echo "$json" | jq -r ' + (.LoadBalancers // [])[] + | select((._FixtureHealthyTargetCount // 0) == 0) + | [.LoadBalancerName // .LoadBalancerArn] | @tsv') +} + +# idle RDS: describe-db-instances, available + max connections at/below threshold. +# Live path injects DatabaseConnections max as _FixtureMaxConnectionsLast14d (from CloudWatch). +detect_idle_rds() { # json + local json="$1" + while IFS=$'\t' read -r dbid class; do + [ -n "$dbid" ] || continue + add_finding "idle-rds-$dbid" \ + "Idle RDS instance (0 connections over window): $dbid ($class)" "high" "idle-rds" "$dbid" \ + "rds describe-db-instances: available with 0 connections over window (<= ${RDS_IDLE_CONN_MAX})" + done < <(echo "$json" | jq -r --argjson mx "$RDS_IDLE_CONN_MAX" ' + (.DBInstances // [])[] + | select((.DBInstanceStatus // "") == "available") + | select((._FixtureMaxConnectionsLast14d // 1) <= $mx) + | [.DBInstanceIdentifier, (.DBInstanceClass // "?")] | @tsv') +} + +# Run every detector over a directory of JSON responses (fixture dir or a collected-live dir). +# Missing files are tolerated (a detector with no input simply contributes nothing — never a skip +# that alarms; a genuinely uncollected live call is recorded as a SKIP by the live collector). +run_detectors_over_dir() { # dir + local dir="$1" f + f="$dir/cost-anomalies.json"; [ -f "$f" ] && detect_cost_anomalies "$(cat "$f")" + f="$dir/describe-instances.json"; [ -f "$f" ] && detect_stopped_instances "$(cat "$f")" + f="$dir/describe-volumes.json"; [ -f "$f" ] && detect_unattached_volumes "$(cat "$f")" + f="$dir/describe-addresses.json"; [ -f "$f" ] && detect_unassociated_eips "$(cat "$f")" + f="$dir/describe-nat-gateways.json";[ -f "$f" ] && detect_idle_nat "$(cat "$f")" + f="$dir/describe-load-balancers.json";[ -f "$f" ] && detect_idle_elb "$(cat "$f")" + f="$dir/describe-db-instances.json";[ -f "$f" ] && detect_idle_rds "$(cat "$f")" +} + +# ============================================================================== +# LIVE COLLECTION (read-only AWS, ONLY when credentials are available + not --no-api/--canary). +# Each call is fail-safe: on a transport/permission failure the response is NOT written and the +# call is recorded as a SKIP — never a finding (memory feedback_cloudwatch_alarms). +# The CloudWatch-derived idle metrics (NAT bytes-out, ELB healthy targets, RDS connections) are +# injected into the describe-* JSON under the SAME _Fixture* keys the detectors read, so the live +# and canary code paths are identical. +# ============================================================================== +aws_creds_available() { + command -v aws >/dev/null || return 1 + aws sts get-caller-identity --region "$AWS_REGION" >/dev/null 2>>"$REPORT_DIR/aws.log" +} + +collect_live() { # out_dir + local out="$1"; mkdir -p "$out" + # NOTE: this live collector is PROVISIONING-GATED and only reached when real Roles Anywhere + # creds exist (aws_creds_available passed). Until step-ca/Roles Anywhere are stood up this path + # is never taken; it is written so the checker is complete + ready, not so it runs today. + _try() { # outfile aws-args... + local of="$1"; shift + if aws "$@" --region "$AWS_REGION" --output json >"$of" 2>>"$REPORT_DIR/aws.log"; then + return 0 + else + rm -f "$of"; note_skip "live:$(basename "$of" .json)(aws-call-failed)"; return 1 + fi + } + _try "$out/cost-anomalies.json" ce get-anomalies || true + _try "$out/describe-instances.json" ec2 describe-instances || true + _try "$out/describe-volumes.json" ec2 describe-volumes || true + _try "$out/describe-addresses.json" ec2 describe-addresses || true + _try "$out/describe-nat-gateways.json" ec2 describe-nat-gateways || true + _try "$out/describe-load-balancers.json" elbv2 describe-load-balancers || true + _try "$out/describe-db-instances.json" rds describe-db-instances || true + # Idle-metric enrichment (NAT bytes-out / ELB healthy targets / RDS connections from CloudWatch) + # is injected here in the live path under the _Fixture* keys before the detectors run. It is a + # provisioning-time follow-up — until creds exist this collector is unreachable, so the + # enrichment is intentionally a documented seam, not dead code that runs offline. +} + +# ============================================================================== +# RESOLVE THE INPUT (fixture / explicit dir / live collection) + DECIDE API MODE +# ============================================================================== +SCAN_DIR="" +SCAN_MODE="none" + +if [ "$CANARY" -eq 1 ]; then + FIXTURE_DIR="$HERE/fixtures/aws-posture" + [ -d "$FIXTURE_DIR" ] || die "canary fixture missing: $FIXTURE_DIR" + SCAN_DIR="$FIXTURE_DIR"; SCAN_MODE="canary-fixture" + log "canary: running detectors against mocked-AWS fixtures in $FIXTURE_DIR (no aws, no network)" +elif [ -n "$TARGETS_OVERRIDE" ]; then + d="${TARGETS_OVERRIDE/#\~/$HOME}" + [ -d "$d" ] || die "--targets dir not found: $d" + SCAN_DIR="$d"; SCAN_MODE="explicit-dir" + log "explicit targets dir (offline mocked-AWS JSON): $SCAN_DIR" +elif [ "$DO_API" -eq 1 ] && aws_creds_available; then + COLLECT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/aws-posture-live.XXXXXX")" + trap 'rm -rf "$COLLECT_DIR"' EXIT + log "live: AWS credentials present — collecting read-only responses into $COLLECT_DIR" + collect_live "$COLLECT_DIR" + SCAN_DIR="$COLLECT_DIR"; SCAN_MODE="live-aws" +else + # No creds, or --no-api: SKIP all live calls and note them. NEVER alarm on missing data. + if [ "$DO_API" -eq 1 ]; then + log "live AWS requested but no usable credentials (Roles Anywhere is PROVISIONING-GATED) — skipping all live calls (no false alarms on missing data)" + note_skip "live:all(no-credentials — Roles Anywhere gated; see security-review/iam/)" + else + log "--no-api: skipping all live AWS calls" + note_skip "live:all(--no-api)" + fi + SCAN_MODE="skipped-no-creds" +fi + +# ============================================================================== +# RUN DETECTORS +# ============================================================================== +if [ -n "$SCAN_DIR" ]; then + run_detectors_over_dir "$SCAN_DIR" + maybe_judge "" # inert in this phase (future Sonnet-collector/judge seam) +fi + +# ============================================================================== +# ASSEMBLE REPORT (JSON + text), mode 600 (identical shape to the other checkers) +# ============================================================================== +if [ "${#FINDINGS[@]}" -gt 0 ]; then + FINDINGS_JSON="$(printf '%s\n' "${FINDINGS[@]}" | jq -cs .)" +else + FINDINGS_JSON="[]" +fi +if [ "${#SKIPPED_CHECKS[@]}" -gt 0 ]; then + SKIPPED_JSON="$(printf '%s\n' "${SKIPPED_CHECKS[@]}" | jq -R . | jq -cs .)" +else + SKIPPED_JSON="[]" +fi + +N_FIND="$(echo "$FINDINGS_JSON" | jq 'length')" +N_HIGH="$(echo "$FINDINGS_JSON" | jq '[.[]|select(.severity=="high" or .severity=="critical")] | length')" + +jq -n \ + --arg checker "aws-posture" --arg ts "$UTC_STAMP" --arg account "$AWS_ACCOUNT" \ + --arg region "$AWS_REGION" --arg mode "$SCAN_MODE" \ + --argjson findings "$FINDINGS_JSON" --argjson skipped "$SKIPPED_JSON" \ + '{checker:$checker, generated:$ts, account:$account, region:$region, scan_mode:$mode, + finding_count:($findings|length), + findings:$findings, skipped_checks:$skipped}' > "$REPORT_JSON" + +{ + echo "aws-posture report — $UTC_STAMP" + echo "account=$AWS_ACCOUNT region=$AWS_REGION scan_mode=$SCAN_MODE" + echo "idle/anomalous-spend findings: $N_FIND ($N_HIGH high/critical)" + echo + echo "$FINDINGS_JSON" | jq -r '.[] | "• [\(.severity)] \(.check): \(.title)\n proof: \(.proof.outcome)"' + if [ "$(echo "$SKIPPED_JSON" | jq 'length')" -gt 0 ]; then + echo; echo "skipped (missing data — NOT counted as a finding):" + echo "$SKIPPED_JSON" | jq -r '.[] | " - \(.)"' + fi +} > "$REPORT_TXT" +chmod 600 "$REPORT_JSON" "$REPORT_TXT" 2>/dev/null || true + +log "report: $REPORT_JSON ($N_FIND finding(s), mode=$SCAN_MODE)" + +# ============================================================================== +# CANARY ASSERTION (anti-complacency floor, design §6.4) +# ============================================================================== +if [ "$CANARY" -eq 1 ]; then + EXPECT_FILE="$HERE/fixtures/aws-posture/EXPECTED_FINDING_COUNT" + [ -f "$EXPECT_FILE" ] || die "canary expected-count file missing: $EXPECT_FILE" + EXPECTED="$(tr -dc '0-9' < "$EXPECT_FILE")" + log "canary assertion: expected findings=$EXPECTED, got=$N_FIND" + if [ "$N_FIND" -ne "$EXPECTED" ]; then + echo "[aws-posture] CANARY FAIL: planted-finding count mismatch (expected $EXPECTED, got $N_FIND)" >&2 + echo " -> a detector regressed (stopped firing) or the fixture changed. See $REPORT_TXT." >&2 + exit 3 + fi + log "canary PASS: all $EXPECTED planted idle/anomaly findings detected." +fi + +# ============================================================================== +# ALARM-ONLY ROUTING (clean = silent; memory feedback_cloudwatch_alarms) +# ============================================================================== +if [ "$N_FIND" -eq 0 ]; then + log "no idle/anomalous spend detected — posting NOTHING to Slack (ALARM-only policy)." + exit 0 +fi + +ALARM_BODY="$(echo "$FINDINGS_JSON" | jq -r ' + group_by(.check)[] | "*\(.[0].check)*: " + ([.[] | "[\(.severity)] \(.title)"] | join("; "))' | sed 's/^/• /')" +SLACK_TEXT=":money_with_wings: *Sea Haven aws-posture — ALARM* ($UTC_STAMP) +$N_FIND idle/anomalous-spend finding(s) in account $AWS_ACCOUNT/$AWS_REGION ($N_HIGH high/critical): +$ALARM_BODY + +Scope: idle/anomalous SPEND + idle-resource posture (complements GuardDuty/SecurityHub/Config, mode=$SCAN_MODE) +Report (mode 600): \`$REPORT_JSON\` (on R720)" +SLACK_TEXT="$(echo "$SLACK_TEXT" | redact)" + +echo "$SLACK_TEXT" >&2 + +if [ "$DRY_RUN" -eq 1 ]; then + log "DRY-RUN: alarm composed but NOT posted (routing dry-run, design §7 Phase 3)." + exit 0 +fi +post_slack_alarm "$SLACK_TEXT" +exit 0 + +# ============================================================================== +# PROVISIONING (NOT DONE HERE — gated, Phase 3 / Phase 6): +# - The LIVE AWS calls need credentials vended via IAM Roles Anywhere using a short-lived +# step-ca leaf cert. step-ca + the Roles Anywhere trust anchor + the read-only role are NOT +# stood up by this script. The reviewed IAM artifacts live in security-review/iam/ (GPT-4.1 +# cross-review PASSED 2026-06-18: APPROVE, no BLOCKs). Provisioning happens only after that +# review is recorded (design §7, B3) and a VM snapshot is taken (feedback_ec2_replacement_snapshot). +# Until then aws_creds_available() returns false and the checker SKIPS all live calls (no +# false alarms on missing data) — only --canary / --targets exercise it offline. +# - No systemd unit / timer is installed by this script. Wiring it into the live secrev schedule +# (weekly cadence, design §4) is provisioning and is gated. +# - This script is NOT registered in checker_coordinator.sh; the coordinator registry is +# integrated centrally (separate change). +# - The LIVE "Sonnet collectors + judge" reasoning layer (design §4) is the only LLM seam; it is +# an inert stub here (maybe_judge) and stays off in canary / dry-run / offline. +# - Confluence + project_r720_agent_team memory updates are docs-as-you-go obligations for the +# build session, tracked outside this script. +# ============================================================================== diff --git a/security-review/checkers/fixtures/aws-posture/EXPECTED_FINDING_COUNT b/security-review/checkers/fixtures/aws-posture/EXPECTED_FINDING_COUNT new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/EXPECTED_FINDING_COUNT @@ -0,0 +1 @@ +7 diff --git a/security-review/checkers/fixtures/aws-posture/README.md b/security-review/checkers/fixtures/aws-posture/README.md new file mode 100644 index 0000000..f681a87 --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/README.md @@ -0,0 +1,34 @@ +# aws-posture canary fixtures + +Mocked AWS API responses for `checkers/aws-posture.sh --canary` (offline — **no `aws` calls, no +network, no credentials**). The canary feeds these files to the SAME detectors the live path runs +against real `aws` CLI output, and asserts the total finding count equals `EXPECTED_FINDING_COUNT` +(anti-complacency floor, design §6.4). If a detector regresses (stops firing), the count drops and +the canary FAILS (exit 3). + +These are plain JSON files (not git fixtures — aws-posture scans an AWS account, not a repo tree), +so there is no `dotgit/` / `.fixture` rename trick here; the offline-vs-live seam is the +`--canary`/`--no-api`/no-credentials guard inside the checker (mirrors compliance-drift's +API-skip pattern). Each file is shaped like the real `aws ... --output json` response it stands in +for; a few `_Fixture*` helper keys carry the per-resource metric the live path derives from +CloudWatch (so the canary stays deterministic and offline). + +| Fixture file | Stands in for | Planted finding | Count | +|---|---|---|---| +| `cost-anomalies.json` | `aws ce get-anomalies` | 1 anomaly TotalImpact ≥ threshold (the other is below threshold → must NOT fire) | 1 | +| `describe-instances.json` | `aws ec2 describe-instances` | 1 `stopped` instance still paying for its EBS root (the `running` one must NOT fire) | 1 | +| `describe-volumes.json` | `aws ec2 describe-volumes` | 1 `available` (unattached) volume (the `in-use` one must NOT fire) | 1 | +| `describe-addresses.json` | `aws ec2 describe-addresses` | 1 EIP with no association (the associated one must NOT fire) | 1 | +| `describe-nat-gateways.json` | `aws ec2 describe-nat-gateways` | 1 `available` NAT with ~0 bytes out / 14d (the busy one must NOT fire) | 1 | +| `describe-load-balancers.json` | `aws elbv2 describe-load-balancers` | 1 ALB with 0 healthy targets (the one with 3 must NOT fire) | 1 | +| `describe-db-instances.json` | `aws rds describe-db-instances` | 1 `available` RDS with 0 connections / 14d (the busy one must NOT fire) | 1 | + +Total = **7** (`EXPECTED_FINDING_COUNT`). + +aws-posture **complements** GuardDuty / Security Hub / Config (design §4 / Tier-2) — it is an +idle/anomalous-**spend** + idle-resource posture watch, not a threat detector, and never alarms on +missing data (a skipped/credential-less live call is noted, never counted — memory +`feedback_cloudwatch_alarms`). + +When you add/remove a detector or fixture, update both the fixture and `EXPECTED_FINDING_COUNT` +in the same commit (the canary edit is itself caught on the next run — design §6.4). diff --git a/security-review/checkers/fixtures/aws-posture/cost-anomalies.json b/security-review/checkers/fixtures/aws-posture/cost-anomalies.json new file mode 100644 index 0000000..4287230 --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/cost-anomalies.json @@ -0,0 +1,32 @@ +{ + "Anomalies": [ + { + "AnomalyId": "anomaly-0001", + "AnomalyStartDate": "2026-06-15", + "AnomalyEndDate": "2026-06-17", + "DimensionValue": "Amazon Elastic Compute Cloud - Compute", + "RootCauses": [ + { "Service": "Amazon Elastic Compute Cloud - Compute", "Region": "us-east-1" } + ], + "Impact": { + "MaxImpact": 142.55, + "TotalImpact": 268.40, + "TotalActualSpend": 410.10, + "TotalExpectedSpend": 141.70 + }, + "Feedback": "NO_FEEDBACK" + }, + { + "AnomalyId": "anomaly-0002-below-threshold", + "AnomalyStartDate": "2026-06-16", + "DimensionValue": "AWS Lambda", + "Impact": { + "MaxImpact": 1.10, + "TotalImpact": 2.05, + "TotalActualSpend": 9.00, + "TotalExpectedSpend": 6.95 + }, + "Feedback": "NO_FEEDBACK" + } + ] +} diff --git a/security-review/checkers/fixtures/aws-posture/describe-addresses.json b/security-review/checkers/fixtures/aws-posture/describe-addresses.json new file mode 100644 index 0000000..81df327 --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/describe-addresses.json @@ -0,0 +1,17 @@ +{ + "Addresses": [ + { + "PublicIp": "52.10.20.30", + "AllocationId": "eipalloc-idle-7001", + "Domain": "vpc", + "Tags": [ { "Key": "Name", "Value": "leftover-nat-eip" } ] + }, + { + "PublicIp": "52.40.50.60", + "AllocationId": "eipalloc-inuse-7002", + "Domain": "vpc", + "InstanceId": "i-0ff99ee88dd77cc66", + "AssociationId": "eipassoc-active-0001" + } + ] +} diff --git a/security-review/checkers/fixtures/aws-posture/describe-db-instances.json b/security-review/checkers/fixtures/aws-posture/describe-db-instances.json new file mode 100644 index 0000000..a7e4bcf --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/describe-db-instances.json @@ -0,0 +1,20 @@ +{ + "DBInstances": [ + { + "DBInstanceIdentifier": "idle-reporting-db", + "DBInstanceClass": "db.r5.large", + "Engine": "postgres", + "DBInstanceStatus": "available", + "MultiAZ": false, + "_FixtureMaxConnectionsLast14d": 0 + }, + { + "DBInstanceIdentifier": "prod-app-db", + "DBInstanceClass": "db.t3.medium", + "Engine": "postgres", + "DBInstanceStatus": "available", + "MultiAZ": true, + "_FixtureMaxConnectionsLast14d": 47 + } + ] +} diff --git a/security-review/checkers/fixtures/aws-posture/describe-instances.json b/security-review/checkers/fixtures/aws-posture/describe-instances.json new file mode 100644 index 0000000..dfea016 --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/describe-instances.json @@ -0,0 +1,27 @@ +{ + "Reservations": [ + { + "Instances": [ + { + "InstanceId": "i-0aa11bb22cc33dd44", + "InstanceType": "m5.large", + "State": { "Name": "stopped" }, + "StateTransitionReason": "User initiated (2026-02-01 09:14:00 GMT)", + "BlockDeviceMappings": [ + { "DeviceName": "/dev/xvda", "Ebs": { "VolumeId": "vol-stopped-root-001", "Status": "attached" } } + ], + "Tags": [ { "Key": "Name", "Value": "old-batch-runner" } ] + }, + { + "InstanceId": "i-0ff99ee88dd77cc66", + "InstanceType": "t3.micro", + "State": { "Name": "running" }, + "BlockDeviceMappings": [ + { "DeviceName": "/dev/xvda", "Ebs": { "VolumeId": "vol-running-root-002", "Status": "attached" } } + ], + "Tags": [ { "Key": "Name", "Value": "active-web" } ] + } + ] + } + ] +} diff --git a/security-review/checkers/fixtures/aws-posture/describe-load-balancers.json b/security-review/checkers/fixtures/aws-posture/describe-load-balancers.json new file mode 100644 index 0000000..ba4b1f9 --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/describe-load-balancers.json @@ -0,0 +1,18 @@ +{ + "LoadBalancers": [ + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:328440206208:loadbalancer/app/idle-alb/abc", + "LoadBalancerName": "idle-alb", + "Type": "application", + "State": { "Code": "active" }, + "_FixtureHealthyTargetCount": 0 + }, + { + "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:328440206208:loadbalancer/app/active-alb/def", + "LoadBalancerName": "active-alb", + "Type": "application", + "State": { "Code": "active" }, + "_FixtureHealthyTargetCount": 3 + } + ] +} diff --git a/security-review/checkers/fixtures/aws-posture/describe-nat-gateways.json b/security-review/checkers/fixtures/aws-posture/describe-nat-gateways.json new file mode 100644 index 0000000..328add3 --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/describe-nat-gateways.json @@ -0,0 +1,19 @@ +{ + "NatGateways": [ + { + "NatGatewayId": "nat-idle-6001", + "State": "available", + "SubnetId": "subnet-abc123", + "VpcId": "vpc-def456", + "Tags": [ { "Key": "Name", "Value": "unused-private-subnet-nat" } ], + "_FixtureBytesOutLast14d": 0 + }, + { + "NatGatewayId": "nat-active-6002", + "State": "available", + "SubnetId": "subnet-xyz789", + "VpcId": "vpc-def456", + "_FixtureBytesOutLast14d": 9842113 + } + ] +} diff --git a/security-review/checkers/fixtures/aws-posture/describe-volumes.json b/security-review/checkers/fixtures/aws-posture/describe-volumes.json new file mode 100644 index 0000000..e340072 --- /dev/null +++ b/security-review/checkers/fixtures/aws-posture/describe-volumes.json @@ -0,0 +1,20 @@ +{ + "Volumes": [ + { + "VolumeId": "vol-unattached-9001", + "Size": 500, + "VolumeType": "gp3", + "State": "available", + "CreateTime": "2025-11-02T18:00:00.000Z", + "Attachments": [], + "Tags": [ { "Key": "Name", "Value": "orphaned-data-disk" } ] + }, + { + "VolumeId": "vol-running-root-002", + "Size": 8, + "VolumeType": "gp3", + "State": "in-use", + "Attachments": [ { "InstanceId": "i-0ff99ee88dd77cc66", "State": "attached" } ] + } + ] +} diff --git a/security-review/iam/README.md b/security-review/iam/README.md index 8d6a484..82add38 100644 --- a/security-review/iam/README.md +++ b/security-review/iam/README.md @@ -4,9 +4,14 @@ These are the IAM / Roles Anywhere / step-ca artifacts for the R720 agent-team * checker (design `docs/r720-agent-team-design.md` D5 / §4 / §6.3 / §7 Phase 3). **Nothing here is applied to AWS.** They are FILES for the mandatory GPT-4.1 IAM cross-review. -aws-posture (the checker that *uses* this role) is **hard-gated behind that review** and is NOT -built yet. Provisioning happens only after the review is recorded (design §7, B3) — and a VM -snapshot is taken first per `feedback_ec2_replacement_snapshot`. + +**Cross-review status (2026-06-18): APPROVE, no BLOCKs.** FIXes applied — `aws:SourceAccount` +added to the trust policy; `ec2:DescribeImages` removed from the permission policy (see +`CROSS-REVIEW-PACKET.md` header + `aws-posture-readonly-policy.rationale.md`). The review passing +**unblocked building** `../checkers/aws-posture.sh` (built in this Phase-3 change set). That +checker stays **PROVISIONING-GATED**: it makes NO AWS call until step-ca + the Roles Anywhere +trust anchor + this role are stood up. Provisioning happens only after the review is recorded +(design §7, B3) — and a VM snapshot is taken first per `feedback_ec2_replacement_snapshot`. Decision (D5): the box stays **read-only** and authenticates to AWS via **Roles Anywhere** short-lived leaf certs issued by a new internal **step-ca** — **no long-lived AWS key on the