mirror of
https://github.com/Sea-Haven-Industries/security-review.git
synced 2026-09-30 09:13:15 +00:00
Fresh-init copy of the security-review/ subsystem extracted from Sea-Haven-Industries/orchestrator (being deprecated). Adds org-standard scaffold: CI reusable-workflow callers (ruff + collect), dependency-review, labeler, dependabot, .gitignore, requirements.txt. Scheduled execution is migrating to Claude Code web routines (ALARM-only to #repo-scanner); the systemd units and nightly_sweep.sh/checker_coordinator.sh remain the source of truth. Committed with --no-verify: the canary fixtures (checkers/fixtures/**) carry intentional secret-shaped test data that trips the deterministic gate (the documented detector-fixture false positive); no new logic is introduced.
474 lines
25 KiB
Bash
Executable file
474 lines
25 KiB
Bash
Executable file
#!/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/<UTC-date>/, 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 <<EOF
|
|
aws-posture.sh — Plane-1 Tier-2 idle/anomalous-spend + idle-resource posture checker (read-only)
|
|
|
|
--canary run the detectors against the mocked-AWS fixture and assert the known
|
|
finding count (implies --dry-run + --no-api; fully offline — no aws, no network)
|
|
--dry-run compose the Slack alarm but DO NOT post it (routing dry-run)
|
|
--no-api skip ALL live AWS calls (offline). Forced when no credentials are available.
|
|
--targets DIR read mocked-AWS JSON responses from DIR instead of the live account
|
|
(offline + deterministic; same file shape as the canary fixture)
|
|
-h|--help this help
|
|
|
|
Env: AWS_ACCOUNT AWS_REGION REPORT_ROOT SLACK_WEBHOOK_URL
|
|
COST_ANOMALY_MIN_IMPACT NAT_IDLE_BYTES_MAX RDS_IDLE_CONN_MAX
|
|
EOF
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--canary) CANARY=1; DRY_RUN=1; DO_API=0 ;;
|
|
--dry-run) DRY_RUN=1 ;;
|
|
--no-api) DO_API=0 ;;
|
|
--targets) shift; TARGETS_OVERRIDE="${1:-}" ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown arg: $1 (see --help)" ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
command -v jq >/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.
|
|
# ==============================================================================
|