mirror of
https://github.com/Sea-Haven-Industries/security-review.git
synced 2026-09-30 20:53: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.
482 lines
25 KiB
Bash
Executable file
482 lines
25 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# doc-drift.sh — Plane-1 / Tier-1 checker for the R720 agent-team.
|
|
#
|
|
# Design refs: docs/r720-agent-team-design.md §4 (Tier 1 roster: doc-drift —
|
|
# "Flags repos whose architecture moved but Confluence/README did not") and §7 Phase 3
|
|
# ("doc-drift + step-ca/Roles Anywhere + aws-posture"). This is the THIRD Plane-1 checker
|
|
# built on the Phase-0 shared substrate (lib/sweep_substrate.sh); it mirrors
|
|
# compliance-drift.sh / dependency-cve.sh conventions VERBATIM so the coordinator (§5) can
|
|
# drive all of them identically. doc-drift is UNGATED (only aws-posture in this phase is
|
|
# hard-gated behind the GPT-4.1 IAM cross-review; that checker is NOT built here).
|
|
#
|
|
# WHAT IT DOES (read-only):
|
|
# Scans the SAME shallow clean clones nightly_sweep.sh already produced in $MIRROR_DIR — it
|
|
# does NOT re-clone (mirrors-first; an optional --refresh re-runs discovery+mirror via the
|
|
# shared substrate). In each mirror it flags repos whose ARCHITECTURE MOVED but the README
|
|
# DID NOT — i.e. documentation drift. The checklist is DETERMINISTIC and GROUNDED in the
|
|
# global CLAUDE.md README obligation; it does NOT invent fuzzy judgments. See "CHECKLIST".
|
|
#
|
|
# This phase is the deterministic core ONLY. The design's "Gemini (large context)" judge
|
|
# layer (§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.
|
|
#
|
|
# 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 drift) 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)
|
|
# discover_repos, mirror_repo-> ONLY on --refresh (reads GH_TOKEN, GH_ORG, MIRROR_DIR, REPORT_DIR)
|
|
# Default path enumerates EXISTING $MIRROR_DIR/*/.git dirs — zero clones, zero network.
|
|
#
|
|
# CANARY / DRY-RUN (offline, no network, no token):
|
|
# --canary runs the checklist against a planted-drift fixture (checkers/fixtures/doc-drift/)
|
|
# and asserts the known drift count. This is the anti-complacency floor (design §6.4) AND the
|
|
# routing dry-run (§7 Phase 3): with --dry-run, the Slack alarm is composed + printed but NOT
|
|
# POSTed. Fully offline-smoke-testable (the checks are filesystem + `git log`, no network).
|
|
#
|
|
# SCOPE / SAFETY:
|
|
# Read-only. All checks are filesystem + local `git log`; NO network, NO token, NO GitHub API
|
|
# (doc-drift has no API-only checks — it is purely tree+history). Fixtures ship git metadata as
|
|
# dotgit/ (renamed to .git/ at run time) so they commit into THIS repo without becoming
|
|
# submodules — the SAME trick compliance-drift / dependency-cve use. A repo with NO README is
|
|
# SKIPPED (compliance-drift owns readme-present); doc-drift never double-flags a missing README.
|
|
#
|
|
# This script does NOT touch agent_team/ or agent-team/, is NOT wired into systemd, and does NOT
|
|
# stand up step-ca / Roles Anywhere / aws-posture — that is Phase-3/6 provisioning (gated). See
|
|
# the "PROVISIONING (NOT DONE HERE)" note at the 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 "[doc-drift] $*" >&2; }
|
|
die() { echo "[doc-drift] 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) ------------------------------------
|
|
GH_ORG="${GH_ORG:-Sea-Haven-Industries}"
|
|
MIRROR_DIR="${MIRROR_DIR:-$HOME/repo-mirrors}"
|
|
REPORT_ROOT="${REPORT_ROOT:-$HOME/sweep-reports/doc-drift}"
|
|
# Docs-only repos describe themselves differently (a handbook is its own doc); skip the
|
|
# architecture-omission scan for them. They still get the staleness check.
|
|
DOCS_ONLY_REPOS="${DOCS_ONLY_REPOS:-engineering-handbook}"
|
|
# Staleness thresholds: README must lag the newest code by BOTH at least this many days AND
|
|
# this many substantial code commits before we call it drift (two-factor = no false alarm on a
|
|
# single quick fix landed after a doc commit; memory feedback_cloudwatch_alarms).
|
|
DOC_DRIFT_STALE_DAYS="${DOC_DRIFT_STALE_DAYS:-60}"
|
|
DOC_DRIFT_STALE_COMMITS="${DOC_DRIFT_STALE_COMMITS:-3}"
|
|
|
|
REFRESH=0 # --refresh: re-run discovery+mirror via substrate (network). Default: reuse mirrors.
|
|
DO_API=1 # --no-api: accepted for interface-parity with the other checkers; doc-drift makes
|
|
# NO API calls, so this flag is a documented no-op (kept so the coordinator
|
|
# can pass a uniform flag set to every Tier-1 checker).
|
|
DRY_RUN=0 # --dry-run: compose the Slack alarm but DO NOT post it (routing dry-run).
|
|
CANARY=0 # --canary: run against the planted-drift fixture + assert the known count.
|
|
TARGETS_OVERRIDE="" # --targets "p1 p2": scan explicit dirs instead of the mirror set.
|
|
|
|
usage() {
|
|
cat >&2 <<EOF
|
|
doc-drift.sh — Plane-1 Tier-1 documentation-drift checker (read-only)
|
|
|
|
--canary run against the planted-drift fixture and assert the known drift count
|
|
(implies --dry-run; fully offline smoke test — no network, no token)
|
|
--dry-run compose the Slack alarm but DO NOT post it (routing dry-run)
|
|
--no-api accepted for parity with the other Tier-1 checkers; doc-drift makes NO
|
|
API calls, so this is a documented no-op
|
|
--refresh re-discover + re-mirror via the shared substrate before scanning (network)
|
|
--targets "a b" scan these explicit repo dirs instead of \$MIRROR_DIR/* (no clone)
|
|
-h|--help this help
|
|
|
|
Env: GH_ORG MIRROR_DIR REPORT_ROOT GH_TOKEN SLACK_WEBHOOK_URL DOCS_ONLY_REPOS
|
|
DOC_DRIFT_STALE_DAYS DOC_DRIFT_STALE_COMMITS
|
|
EOF
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--canary) CANARY=1; DRY_RUN=1 ;;
|
|
--dry-run) DRY_RUN=1 ;;
|
|
--no-api) DO_API=0 ;;
|
|
--refresh) REFRESH=1 ;;
|
|
--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"
|
|
command -v git >/dev/null || die "git 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/doc-drift.log" # name the substrate's post_slack_alarm() references
|
|
REPORT_JSON="$REPORT_DIR/doc-drift.json"
|
|
REPORT_TXT="$REPORT_DIR/doc-drift.txt"
|
|
|
|
# doc-drift makes NO API calls, so DO_API is a documented no-op kept only for coordinator
|
|
# flag-parity; surface it in the run banner so the chosen value is auditable (and used).
|
|
log "=== doc-drift $UTC_STAMP (canary=$CANARY dry_run=$DRY_RUN refresh=$REFRESH api=${DO_API}[no-op] stale_days=$DOC_DRIFT_STALE_DAYS stale_commits=$DOC_DRIFT_STALE_COMMITS) ==="
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# CHECKLIST (grounded — every item cites the README obligation; nothing invented):
|
|
#
|
|
# readme-omits-component README exists but omits a major existing component
|
|
# present in the tree (top-level service dir, SAM/CDK stack,
|
|
# Lambda handler dir, openapi/docs API spec)
|
|
# -> global CLAUDE.md: "README must accurately describe
|
|
# architecture, services, data flow, and configuration"
|
|
# readme-stale-vs-code README last-touched commit far older than the newest code
|
|
# commit (>= DOC_DRIFT_STALE_DAYS) AND >= DOC_DRIFT_STALE_COMMITS
|
|
# substantial code commits landed after the README was touched
|
|
# -> global CLAUDE.md: "update the README in the same commit"
|
|
#
|
|
# A repo with NO README is SKIPPED (compliance-drift owns readme-present; double-flagging would
|
|
# be a false alarm). Each emitted finding follows the spirit of finding.schema.json
|
|
# (id/title/severity/category/proof/status) so the coordinator can route it like an agentic
|
|
# finding. category="other" (doc drift is not one of the schema's security categories).
|
|
# status="confirmed" only for deterministic filesystem/git-history facts. The future Gemini
|
|
# judge (design §4) is an inert stub (maybe_judge) — never invoked offline / in this phase.
|
|
# ------------------------------------------------------------------------------
|
|
|
|
# Drift accumulator: one JSON object per finding, appended to a bash array.
|
|
declare -a FINDINGS=()
|
|
add_finding() { # repo id title severity check proof
|
|
local repo="$1" id="$2" title="$3" sev="$4" check="$5" proof="$6"
|
|
FINDINGS+=( "$(jq -n \
|
|
--arg repo "$repo" --arg id "$id" --arg title "$title" --arg sev "$sev" \
|
|
--arg check "$check" --arg proof "$proof" \
|
|
'{repo:$repo, id:($repo+"-"+$id), title:$title, severity:$sev, category:"other",
|
|
check:$check, status:"confirmed", proof:{outcome:$proof}}')" )
|
|
}
|
|
declare -a SKIPPED_CHECKS=() # (repo:check) checks skipped on missing data — reported, never alarmed
|
|
note_skip() { SKIPPED_CHECKS+=( "$1" ); }
|
|
|
|
in_csv() { # needle csv -> 0 if present
|
|
local n="$1" csv="$2"; case ",$csv," in *",$n,"*) return 0 ;; *) return 1 ;; esac
|
|
}
|
|
|
|
# Inert future seam (design §4 "Gemini (large context)" judge): in LIVE mode an ambiguous
|
|
# omission ("is this component material enough to require a README mention?") could be escalated
|
|
# to a large-context judge. This phase keeps the deterministic core 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
|
|
}
|
|
|
|
# --- Does a README mention a component name? (case-insensitive, word-ish, deterministic) ----
|
|
# Matches the bare name OR the name with a trailing slash (how a dir is usually cited). Strips
|
|
# a leading "the " never matters; we test the literal token. Pure grep, no fuzzy matching.
|
|
readme_mentions() { # readme_file name
|
|
local rf="$1" name="$2"
|
|
# Escape regex metacharacters in the component name (defensive; dir names are usually plain).
|
|
local esc; esc="$(printf '%s' "$name" | sed -E 's/[][(){}.*+?^$|\\/]/\\&/g')"
|
|
grep -qiE "(^|[^A-Za-z0-9_-])${esc}([^A-Za-z0-9_-]|/|$)" "$rf" 2>/dev/null
|
|
}
|
|
|
|
# --- Enumerate the major components present in a repo tree (deterministic) ------
|
|
# Emits "TYPE<TAB>label<TAB>mention_token" lines. mention_token is what the README must contain.
|
|
# service-dir a top-level directory whose name ends in -service or -api, or named api/web/worker
|
|
# sam-cdk-stack a SAM/CDK stack root (template.yaml | app.py at a stack root | cdk.json)
|
|
# lambda-dir a Lambda handler dir (a dir named handlers/ or containing handler.* / app.py under handlers/)
|
|
# api-spec an openapi/ or docs/ directory or an openapi.* / swagger.* spec file
|
|
enumerate_components() { # repo_dir -> TSV lines
|
|
local dir="$1" d nm
|
|
|
|
# 1) top-level service-ish directories (the unit a README is expected to name)
|
|
for d in "$dir"/*/; do
|
|
[ -d "$d" ] || continue
|
|
nm="$(basename "$d")"
|
|
case "$nm" in
|
|
.git|.github|node_modules|dist|build|vendor|__pycache__|.venv) continue ;;
|
|
esac
|
|
case "$nm" in
|
|
*-service|*-api|api|web|worker|backend|frontend)
|
|
printf 'service-dir\t%s\t%s\n' "$nm" "$nm" ;;
|
|
esac
|
|
done
|
|
|
|
# 2) SAM / CDK stack roots
|
|
if [ -f "$dir/template.yaml" ] || [ -f "$dir/template.yml" ]; then
|
|
printf 'sam-cdk-stack\t%s\t%s\n' "template.yaml (SAM stack)" "template.yaml"
|
|
fi
|
|
if [ -f "$dir/cdk.json" ]; then
|
|
printf 'sam-cdk-stack\t%s\t%s\n' "cdk.json (CDK app)" "cdk.json"
|
|
fi
|
|
|
|
# 3) Lambda handler dirs: a top-level/handlers-rooted dir literally named "handlers"
|
|
while IFS= read -r d; do
|
|
[ -n "$d" ] || continue
|
|
printf 'lambda-dir\t%s\t%s\n' "handlers/ (Lambda handlers)" "handlers"
|
|
break # one mention requirement for the handlers tree is enough
|
|
done < <(find "$dir" -maxdepth 2 -type d -name handlers -not -path '*/.git/*' 2>/dev/null)
|
|
|
|
# 4) API spec: an openapi/ or docs/ dir, or an openapi.*/swagger.* file
|
|
if [ -d "$dir/openapi" ]; then
|
|
printf 'api-spec\t%s\t%s\n' "openapi/ (API spec)" "openapi"
|
|
elif find "$dir" -maxdepth 2 \( -iname 'openapi.*' -o -iname 'swagger.*' \) -not -path '*/.git/*' -print -quit 2>/dev/null | grep -q .; then
|
|
printf 'api-spec\t%s\t%s\n' "openapi/swagger spec" "openapi"
|
|
fi
|
|
}
|
|
|
|
# --- README last-touch epoch vs newest code commit (staleness, deterministic git log) -------
|
|
# Returns the staleness facts on stdout as TSV "readme_epoch<TAB>newest_code_epoch<TAB>commits_after".
|
|
# commits_after = count of commits that touched code (non-doc) files AFTER the README's last touch.
|
|
# Code = anything that is NOT a README/markdown/LICENSE/.gitignore/docs file. Prints nothing if
|
|
# the repo has no git history or no README in history (caller treats that as "cannot assess").
|
|
readme_staleness_facts() { # repo_dir
|
|
local dir="$1"
|
|
command -v git >/dev/null || return 0
|
|
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || return 0
|
|
|
|
# README last-touch (committer epoch of the most recent commit touching README.md).
|
|
local rd_epoch
|
|
rd_epoch="$(git -C "$dir" log -1 --format='%ct' -- README.md 2>/dev/null || true)"
|
|
[ -n "$rd_epoch" ] || return 0 # README not in history -> cannot assess staleness
|
|
|
|
# Newest commit touching a CODE path (exclude docs/markdown/license/config-noise).
|
|
local code_epoch
|
|
code_epoch="$(git -C "$dir" log -1 --format='%ct' -- \
|
|
':(exclude)README.md' ':(exclude)*.md' ':(exclude)docs/**' \
|
|
':(exclude)LICENSE' ':(exclude).gitignore' ':(exclude).github/**' \
|
|
2>/dev/null || true)"
|
|
[ -n "$code_epoch" ] || return 0 # no code commits -> nothing to be stale against
|
|
|
|
# Count CODE commits strictly AFTER the README's last touch.
|
|
local commits_after
|
|
commits_after="$(git -C "$dir" rev-list --count "--since=@${rd_epoch}" HEAD -- \
|
|
':(exclude)README.md' ':(exclude)*.md' ':(exclude)docs/**' \
|
|
':(exclude)LICENSE' ':(exclude).gitignore' ':(exclude).github/**' \
|
|
2>/dev/null || echo 0)"
|
|
printf '%s\t%s\t%s\n' "$rd_epoch" "$code_epoch" "${commits_after:-0}"
|
|
}
|
|
|
|
# ==============================================================================
|
|
# PER-REPO CHECK (offline; filesystem + local git log only)
|
|
# ==============================================================================
|
|
check_repo() { # repo_name repo_dir
|
|
local repo="$1" dir="$2"
|
|
local docs_only=0; in_csv "$repo" "$DOCS_ONLY_REPOS" && docs_only=1
|
|
|
|
# No README -> doc-drift cannot assess drift; compliance-drift owns readme-present. SKIP.
|
|
if [ ! -f "$dir/README.md" ]; then
|
|
note_skip "$repo:doc-drift(no-readme — compliance-drift owns readme-present)"
|
|
return
|
|
fi
|
|
local readme="$dir/README.md"
|
|
|
|
# --- readme-omits-component (skip for docs-only repos: they document differently) ---
|
|
if [ "$docs_only" -eq 0 ]; then
|
|
local type label token
|
|
while IFS=$'\t' read -r type label token; do
|
|
[ -n "$token" ] || continue
|
|
if ! readme_mentions "$readme" "$token"; then
|
|
add_finding "$repo" "readme-omits-$(printf '%s' "$type-$token" | tr -c 'A-Za-z0-9-' '-')" \
|
|
"README omits existing component: $label" "medium" "readme-omits-component" \
|
|
"global CLAUDE.md: README must accurately describe architecture/services (present in tree, absent from README: $label)"
|
|
fi
|
|
done < <(enumerate_components "$dir")
|
|
else
|
|
note_skip "$repo:readme-omits-component(docs-only)"
|
|
fi
|
|
|
|
# --- readme-stale-vs-code (two-factor: age in days AND code-commits-after) ---
|
|
local facts; facts="$(readme_staleness_facts "$dir")"
|
|
if [ -z "$facts" ]; then
|
|
note_skip "$repo:readme-stale-vs-code(no-history-or-no-readme-in-history)"
|
|
else
|
|
local rd_epoch code_epoch commits_after age_days
|
|
IFS=$'\t' read -r rd_epoch code_epoch commits_after <<< "$facts"
|
|
age_days=$(( (code_epoch - rd_epoch) / 86400 ))
|
|
[ "$age_days" -lt 0 ] && age_days=0
|
|
if [ "$age_days" -ge "$DOC_DRIFT_STALE_DAYS" ] && [ "$commits_after" -ge "$DOC_DRIFT_STALE_COMMITS" ]; then
|
|
add_finding "$repo" "readme-stale" \
|
|
"README is stale: ${age_days}d behind newest code, ${commits_after} code commit(s) since last README touch" \
|
|
"medium" "readme-stale-vs-code" \
|
|
"global CLAUDE.md: update the README in the same commit as functionality changes (thresholds: >=${DOC_DRIFT_STALE_DAYS}d AND >=${DOC_DRIFT_STALE_COMMITS} code commits)"
|
|
fi
|
|
fi
|
|
|
|
maybe_judge "" # inert in this phase (future Gemini large-context seam)
|
|
}
|
|
|
|
# ==============================================================================
|
|
# TARGET RESOLUTION
|
|
# ==============================================================================
|
|
declare -a REPO_NAMES=(); declare -A REPO_DIR=()
|
|
|
|
if [ "$CANARY" -eq 1 ]; then
|
|
FIXTURE_ROOT="$HERE/fixtures/doc-drift"
|
|
[ -d "$FIXTURE_ROOT" ] || die "canary fixture missing: $FIXTURE_ROOT"
|
|
# Pin the exception lists + thresholds the fixtures were authored against, so the canary is
|
|
# self-contained and deterministic regardless of the operator's env.
|
|
DOCS_ONLY_REPOS=""
|
|
DOC_DRIFT_STALE_DAYS=60
|
|
DOC_DRIFT_STALE_COMMITS=3
|
|
# Fixtures ship their git metadata as `dotgit/` (not `.git/`) so they are committable into THIS
|
|
# repo without becoming nested submodules. Materialize them into a temp work area — copy each
|
|
# fixture and rename dotgit -> .git — so the README/git-log checks run against a real git
|
|
# checkout. The temp area is mode 700 and removed on exit (same trick as compliance-drift.sh).
|
|
FIXTURE_WORK="$(mktemp -d "${TMPDIR:-/tmp}/doc-drift-canary.XXXXXX")"
|
|
trap 'rm -rf "$FIXTURE_WORK"' EXIT
|
|
log "canary: materializing planted-drift fixtures from $FIXTURE_ROOT into $FIXTURE_WORK"
|
|
for d in "$FIXTURE_ROOT"/*/; do
|
|
[ -d "$d/dotgit" ] || continue # only fixture repos (skip README.md, EXPECTED_* etc.)
|
|
nm="$(basename "$d")"
|
|
cp -R "$d" "$FIXTURE_WORK/$nm"
|
|
mv "$FIXTURE_WORK/$nm/dotgit" "$FIXTURE_WORK/$nm/.git"
|
|
REPO_NAMES+=( "$nm" ); REPO_DIR["$nm"]="$FIXTURE_WORK/$nm"
|
|
done
|
|
elif [ -n "$TARGETS_OVERRIDE" ]; then
|
|
# shellcheck disable=SC2206 # intentional word-split of the space-separated --targets list
|
|
arr=( $TARGETS_OVERRIDE )
|
|
for p in "${arr[@]}"; do p="${p/#\~/$HOME}"; nm="$(basename "$p")"; REPO_NAMES+=( "$nm" ); REPO_DIR["$nm"]="$p"; done
|
|
log "explicit targets: ${REPO_NAMES[*]}"
|
|
else
|
|
if [ "$REFRESH" -eq 1 ]; then
|
|
[ -n "${GH_TOKEN:-}" ] || die "--refresh needs GH_TOKEN"
|
|
command -v curl >/dev/null || die "--refresh needs curl"
|
|
mkdir -p "$MIRROR_DIR"
|
|
log "refresh: re-discovering + mirroring via shared substrate (no separate clone path)"
|
|
DISCOVERED="$REPORT_DIR/discovered.tsv"
|
|
if discover_repos > "$DISCOVERED" 2>>"$REPORT_DIR/discover.log" && [ -s "$DISCOVERED" ]; then
|
|
while IFS=$'\t' read -r name url branch; do
|
|
[ -n "$name" ] || continue
|
|
mirror_repo "$name" "$url" "$branch" || log " mirror FAILED: $name (will use stale mirror if present)"
|
|
done < "$DISCOVERED"
|
|
else
|
|
log "discovery failed — falling back to existing mirrors (coverage may be stale)"
|
|
fi
|
|
fi
|
|
# Default + post-refresh: enumerate EXISTING mirrors. No clone here — reuse the sweep's clones.
|
|
[ -d "$MIRROR_DIR" ] || die "mirror dir not found: $MIRROR_DIR (run nightly_sweep.sh first, or use --refresh/--targets)"
|
|
for d in "$MIRROR_DIR"/*/; do
|
|
[ -d "$d/.git" ] || continue
|
|
nm="$(basename "$d")"; REPO_NAMES+=( "$nm" ); REPO_DIR["$nm"]="${d%/}"
|
|
done
|
|
log "reusing ${#REPO_NAMES[@]} existing mirror(s) in $MIRROR_DIR (no re-clone)"
|
|
fi
|
|
|
|
[ "${#REPO_NAMES[@]}" -gt 0 ] || die "no repos to scan"
|
|
|
|
# ==============================================================================
|
|
# RUN CHECKS
|
|
# ==============================================================================
|
|
for nm in "${REPO_NAMES[@]}"; do
|
|
check_repo "$nm" "${REPO_DIR[$nm]}"
|
|
done
|
|
|
|
# ==============================================================================
|
|
# ASSEMBLE REPORT (JSON + text), mode 600 (identical shape to compliance-drift)
|
|
# ==============================================================================
|
|
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_DRIFT="$(echo "$FINDINGS_JSON" | jq 'length')"
|
|
N_HIGH="$(echo "$FINDINGS_JSON" | jq '[.[]|select(.severity=="high")] | length')"
|
|
N_REPOS_DRIFTED="$(echo "$FINDINGS_JSON" | jq '[.[].repo] | unique | length')"
|
|
|
|
jq -n \
|
|
--arg checker "doc-drift" --arg ts "$UTC_STAMP" --arg org "$GH_ORG" \
|
|
--argjson scanned "${#REPO_NAMES[@]}" \
|
|
--argjson findings "$FINDINGS_JSON" --argjson skipped "$SKIPPED_JSON" \
|
|
'{checker:$checker, generated:$ts, org:$org,
|
|
repos_scanned:$scanned, drift_count:($findings|length),
|
|
repos_with_drift:([$findings[].repo]|unique|length),
|
|
findings:$findings, skipped_checks:$skipped}' > "$REPORT_JSON"
|
|
|
|
{
|
|
echo "doc-drift report — $UTC_STAMP"
|
|
echo "org=$GH_ORG repos_scanned=${#REPO_NAMES[@]} stale_thresholds=${DOC_DRIFT_STALE_DAYS}d/${DOC_DRIFT_STALE_COMMITS}commits"
|
|
echo "drift findings: $N_DRIFT ($N_HIGH high) across $N_REPOS_DRIFTED repo(s)"
|
|
echo
|
|
echo "$FINDINGS_JSON" | jq -r '.[] | "• [\(.severity)] \(.repo): \(.title)\n rule: \(.proof.outcome)"'
|
|
if [ "$(echo "$SKIPPED_JSON" | jq 'length')" -gt 0 ]; then
|
|
echo; echo "skipped checks (missing data / not doc-drift's job — NOT counted as drift):"
|
|
echo "$SKIPPED_JSON" | jq -r '.[] | " - \(.)"'
|
|
fi
|
|
} > "$REPORT_TXT"
|
|
chmod 600 "$REPORT_JSON" "$REPORT_TXT" 2>/dev/null || true
|
|
|
|
log "report: $REPORT_JSON ($N_DRIFT drift finding(s), $N_REPOS_DRIFTED repo(s))"
|
|
|
|
# ==============================================================================
|
|
# CANARY ASSERTION (anti-complacency floor, design §6.4)
|
|
# ==============================================================================
|
|
if [ "$CANARY" -eq 1 ]; then
|
|
EXPECT_FILE="$HERE/fixtures/doc-drift/EXPECTED_DRIFT_COUNT"
|
|
[ -f "$EXPECT_FILE" ] || die "canary expected-count file missing: $EXPECT_FILE"
|
|
EXPECTED="$(tr -dc '0-9' < "$EXPECT_FILE")"
|
|
log "canary assertion: expected drift=$EXPECTED, got=$N_DRIFT"
|
|
if [ "$N_DRIFT" -ne "$EXPECTED" ]; then
|
|
echo "[doc-drift] CANARY FAIL: planted-drift count mismatch (expected $EXPECTED, got $N_DRIFT)" >&2
|
|
echo " -> the checklist regressed (a check stopped firing) or the fixture changed. See $REPORT_TXT." >&2
|
|
exit 3
|
|
fi
|
|
log "canary PASS: all $EXPECTED planted drifts detected."
|
|
fi
|
|
|
|
# ==============================================================================
|
|
# ALARM-ONLY ROUTING (clean = silent; memory feedback_cloudwatch_alarms)
|
|
# ==============================================================================
|
|
if [ "$N_DRIFT" -eq 0 ]; then
|
|
log "no confirmed drift — posting NOTHING to Slack (ALARM-only policy)."
|
|
exit 0
|
|
fi
|
|
|
|
ALARM_BODY="$(echo "$FINDINGS_JSON" | jq -r '
|
|
group_by(.repo)[] | "*\(.[0].repo)*: " + ([.[] | "[\(.severity)] \(.title)"] | join("; "))' | sed 's/^/• /')"
|
|
SLACK_TEXT=":memo: *Sea Haven doc-drift — ALARM* ($UTC_STAMP)
|
|
$N_DRIFT documentation-drift finding(s) across $N_REPOS_DRIFTED repo(s) ($N_HIGH high):
|
|
$ALARM_BODY
|
|
|
|
Checks: README-omits-component · README-stale-vs-code (architecture moved, docs did not)
|
|
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):
|
|
# - No systemd unit / timer is installed by this script. Wiring it into the live
|
|
# sea-haven-secrev schedule (or a sibling timer) is provisioning and is gated.
|
|
# - This script is NOT registered in checker_coordinator.sh; the coordinator registry is
|
|
# integrated centrally (separate change), so doc-drift is not yet driven by the coordinator.
|
|
# - step-ca / IAM Roles Anywhere / the read-only AWS role / aws-posture are NOT stood up or
|
|
# built here. The IAM artifacts authored alongside this checker (security-review/iam/) are
|
|
# FILES for the mandatory GPT-4.1 cross-review; aws-posture itself is hard-gated behind that
|
|
# review and is built only after it is recorded (design §7, B3).
|
|
# - The LIVE "Gemini (large context)" doc-drift judge (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.
|
|
# ==============================================================================
|