diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ac7af3..0e92c49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,4 +85,15 @@ jobs: - run: npm run db:migrate - run: npm run db:seed:ci - run: npx playwright install --with-deps chromium - - run: npm run test:e2e:smoke + - name: Integration E2E + run: ./scripts/integration-e2e.sh + + - name: Adversarial tier + run: | + npm run build + node .next/standalone/server.js & + for _ in $(seq 1 60); do + curl -fsS http://localhost:3000 >/dev/null 2>&1 && break + sleep 2 + done + bash ./scripts/adversarial.sh diff --git a/.harness/profile.yaml b/.harness/profile.yaml index 05ebe4f..e782638 100644 --- a/.harness/profile.yaml +++ b/.harness/profile.yaml @@ -17,11 +17,14 @@ requires: - .cursor/hooks.json - .harness/profile.yaml - .harness/VERSION + - specs/threat-model.yaml + - docs/adr/0000-threat-model.md hooks: stop: verify-on-stop commands: verify: ./scripts/verify.sh integration: ./scripts/integration-e2e.sh + adversarial: ./scripts/adversarial.sh bundles_applied: - cloud-guards - verify-on-stop @@ -38,7 +41,9 @@ extensions: compose: deny_compose_from_worktree: true integration_cmd: ./scripts/integration-e2e.sh + adversarial_cmd: ./scripts/adversarial.sh integration_ci_required: true + adversarial_ci_required: true harness_disclosure: schema: harness-disclosure/v1 scope: repo diff --git a/AGENTS.md b/AGENTS.md index 35b7059..e93d172 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,8 @@ Full check including smoke E2E (signed-in smoke **skips locally** unless `PLAYWR ```bash npm run verify:all +# Or integration script (same as CI e2e-smoke job): +./scripts/integration-e2e.sh ``` ## Smoke E2E (must stay green on PR) diff --git a/docs/adr/0000-threat-model.md b/docs/adr/0000-threat-model.md new file mode 100644 index 0000000..ef636ba --- /dev/null +++ b/docs/adr/0000-threat-model.md @@ -0,0 +1,33 @@ +# ADR 0000: Threat Model — Caller, Trust Boundary, Authentication + +**Status:** Accepted +**Date:** 2026-07-13 +**Product:** Autonomous EHS Management System + +## Context + +EHS inbound integration and cron routes accept authenticated callers only. Cooperative Playwright smoke validates configured flows. Tier-3 adversarial tests unauthenticated inbound integration posts. + +See `specs/threat-model.yaml` and `scripts/adversarial.sh`. + +## Decision + +### Principals + +| Principal | Routes | +|-----------|--------| +| `anonymous` | none on integration inbound | +| `integration_service` | bearer/integration secret on inbound webhook | +| `cron_scheduler` | `CRON_SECRET` bearer on cron routes | + +### Trust boundary + +`POST /api/integration/inbound` requires integration authentication before payload processing. + +### Authentication + +API route validates integration credentials; missing auth → `401 Unauthorized`. + +## References + +- `specs/threat-model.yaml`, `scripts/adversarial.sh` diff --git a/scripts/adversarial.sh b/scripts/adversarial.sh new file mode 100755 index 0000000..e714892 --- /dev/null +++ b/scripts/adversarial.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Tier-3 adversarial oracle — unauthenticated inbound integration. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +BASE="${ADVERSARIAL_BASE_URL:-http://localhost:3000}" + +log() { echo ""; echo "== adversarial: $* =="; } + +if ! curl -fsS "$BASE" >/dev/null 2>&1; then + echo "App not running at $BASE — start dev server or run integration-e2e first" >&2 + exit 1 +fi + +# deny_case: anonymous_integration_inbound +log "anonymous_integration_inbound (expect 401)" +code=$(curl -s -o /tmp/ehs-adversarial.json -w "%{http_code}" \ + -X POST "$BASE/api/integration/inbound" \ + -H "Content-Type: application/json" \ + -d '{"kind":"hris_membership_sync","organizationId":"00000000-0000-4000-8000-000000000001","workerEmail":"adversarial@example.com"}') +[[ "$code" == "401" ]] +grep -qi 'Unauthorized' /tmp/ehs-adversarial.json +echo " ${code} (as expected)" + +echo "" +echo "adversarial: ok" diff --git a/scripts/check-threat-model.sh b/scripts/check-threat-model.sh new file mode 100755 index 0000000..c913c6a --- /dev/null +++ b/scripts/check-threat-model.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Validate threat-model artifact + adversarial tier for integration repos. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +errors=0 +PROFILE="$(grep '^profile:' .harness/profile.yaml 2>/dev/null | awk '{print $2}' || echo solo)" +INTEGRATION_CMD="$(grep -A5 'commands:' .harness/profile.yaml 2>/dev/null | grep 'integration:' | head -1 | awk '{print $2}' || true)" + +if [[ -z "$INTEGRATION_CMD" || "$INTEGRATION_CMD" == "null" ]]; then + for candidate in scripts/demo.sh scripts/integration-e2e.sh scripts/integration-smoke.sh scripts/smoke-test.sh; do + if [[ -f "$ROOT/$candidate" ]]; then + INTEGRATION_CMD="./$candidate" + break + fi + done + if [[ -z "$INTEGRATION_CMD" ]]; then + shopt -s nullglob + for candidate in "$ROOT"/scripts/smoke-test*.sh; do + INTEGRATION_CMD="./scripts/$(basename "$candidate")" + break + done + shopt -u nullglob + fi +fi + +EXEMPT=0 +case "$PROFILE" in + harness-lab|eval) EXEMPT=1 ;; +esac +if [[ -z "$INTEGRATION_CMD" || "$INTEGRATION_CMD" == "null" ]]; then + echo "check-threat-model: skip (no integration tier; profile=$PROFILE)" + exit 0 +fi +if [[ "$EXEMPT" -eq 1 ]]; then + echo "check-threat-model: skip (exempt profile=$PROFILE)" + exit 0 +fi + +echo "== threat-model: integration=$INTEGRATION_CMD profile=$PROFILE ==" + +require_file() { + local path="$1" + if [[ ! -f "$path" ]]; then + echo "MISSING: $path" >&2 + errors=$((errors + 1)) + fi +} + +require_file "specs/threat-model.yaml" +if [[ ! -f docs/adr/0000-threat-model.md && ! -f specs/decisions/0000-threat-model.md ]]; then + echo "MISSING: docs/adr/0000-threat-model.md or specs/decisions/0000-threat-model.md" >&2 + errors=$((errors + 1)) +fi +require_file "scripts/adversarial.sh" + +if ! grep -q 'adversarial:' .harness/profile.yaml; then + echo "MISSING: requires.commands.adversarial in .harness/profile.yaml" >&2 + errors=$((errors + 1)) +fi + +echo "== threat-model: yaml schema ==" +python3 - <<'PY' || errors=$((errors + 1)) +import pathlib, re, sys + +root = pathlib.Path(".") +text = (root / "specs/threat-model.yaml").read_text() +if "schema: threat-model/v1" not in text: + print("BAD SCHEMA: specs/threat-model.yaml (want threat-model/v1)", file=sys.stderr) + raise SystemExit(1) +cells_part = text.split("deny_cases:")[0] +cell_ids = re.findall(r"^\s*- id: (\S+)", cells_part, re.M) +deny_part = text.split("deny_cases:")[-1] if "deny_cases:" in text else "" +deny_ids = re.findall(r"^\s*- id: (\S+)", deny_part, re.M) +deny_cells = re.findall(r"^\s*cell: (\S+)", deny_part, re.M) +if not cell_ids or not deny_ids: + print("EMPTY: cells or deny_cases", file=sys.stderr) + raise SystemExit(1) +cell_set = set(cell_ids) +for cid in deny_cells: + if cid not in cell_set: + print(f"BAD deny_case cell: {cid!r}", file=sys.stderr) + raise SystemExit(1) +adv = (root / "scripts/adversarial.sh").read_text() +for did in deny_ids: + if did not in adv: + print(f"TRACEABILITY: deny_case {did!r} not in adversarial.sh", file=sys.stderr) + raise SystemExit(1) +print(f"yaml ok: {len(cell_ids)} cells, {len(deny_ids)} deny_cases") +PY + +ADR="" +[[ -f docs/adr/0000-threat-model.md ]] && ADR="docs/adr/0000-threat-model.md" +[[ -z "$ADR" && -f specs/decisions/0000-threat-model.md ]] && ADR="specs/decisions/0000-threat-model.md" +if [[ -n "$ADR" ]]; then + lower="$(tr '[:upper:]' '[:lower:]' < "$ADR")" + for kw in principal "trust bound" authentication; do + if ! grep -qi "$kw" <<< "$lower"; then + echo "ADR-0000 missing keyword: $kw ($ADR)" >&2 + errors=$((errors + 1)) + fi + done +fi + +if [[ "$errors" -gt 0 ]]; then + echo "check-threat-model: FAILED ($errors errors)" >&2 + exit 1 +fi + +echo "check-threat-model: ok" diff --git a/scripts/verify.sh b/scripts/verify.sh index 7bff21c..47a2f40 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -17,3 +17,8 @@ echo "==> verify (lint + typecheck + unit tests)" npm run verify echo "verify: ok (ci/web parity; add DATABASE_URL + test:e2e:smoke for full QA)" + +if [[ -f ./scripts/check-threat-model.sh ]]; then + echo "==> threat model gate" + bash ./scripts/check-threat-model.sh +fi diff --git a/specs/threat-model.yaml b/specs/threat-model.yaml new file mode 100644 index 0000000..f399d7a --- /dev/null +++ b/specs/threat-model.yaml @@ -0,0 +1,23 @@ +schema: threat-model/v1 +product: Autonomous EHS Management System +updated: "2026-07-13" + +cells: + - id: integration_inbound + method: POST + path: /api/integration/inbound + principals: [integration_service] + auth_mechanism: integration_bearer + trust_boundary: integration_authenticated + establishment_failure: "401 Unauthorized" + +deny_cases: + - id: anonymous_integration_inbound + cell: integration_inbound + principal: anonymous + fixture: + path: /api/integration/inbound + body: '{}' + expect: + status: 401 + reason_substring: Unauthorized