diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/casan-log.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/casan-log.sh index 447f945..9ad36f1 100644 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/casan-log.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/casan-log.sh @@ -24,8 +24,12 @@ casan_log() { local lvl="$1" comp="$2" shift 2 [ "$(casan_log_num "$lvl")" -le "$CASAN_LOG_THRESHOLD" ] || return 0 + # SEC-27 (X-02): log messages carry attacker-influenced data (action names, tool + # output snippets). Strip control chars — ESC/CSI (terminal-escape injection that + # rewrites a reviewer's screen) and CR/LF (fake-log-line injection) — keeping tab. + local msg; msg="$(printf '%s' "$*" | tr -d '\000-\010\012-\037\177')" printf '[%s] %s [%s] %s\n' \ "$(printf '%s' "$lvl" | tr '[:lower:]' '[:upper:]')" \ "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ - "$comp" "$*" >&2 + "$comp" "$msg" >&2 } diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh index 501b75b..033bf54 100755 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/ci-harness-gate.sh @@ -104,6 +104,10 @@ run "phase-sec20-toolchain" bash "$TESTS/phase-sec20-tests.sh" run "phase-sec21-model-budget" bash "$TESTS/phase-sec21-tests.sh" run "phase-sec07-approval" bash "$TESTS/phase-sec07-tests.sh" run "phase-sec10-agent-identity" bash "$TESTS/phase-sec10-tests.sh" +# Plan-16 P2 (depth / hardening) +run "phase-sec13-ssrf" bash "$TESTS/phase-sec13-tests.sh" +run "phase-sec27-log-controlchar" bash "$TESTS/phase-sec27-tests.sh" +run "phase-sec28-path-traversal" bash "$TESTS/phase-sec28-tests.sh" # ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently # drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI). diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/dashboard-server.py b/AINative_OKR_CASAN5/.specify/scripts/bash/dashboard-server.py index 48e60e0..81619ab 100644 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/dashboard-server.py +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/dashboard-server.py @@ -77,4 +77,11 @@ BIND = os.environ.get("CASAN_DASHBOARD_BIND", "127.0.0.1") if __name__ == "__main__": + # SEC-13 (M-09): the dashboard has no auth, so binding to all interfaces exposes + # it to the network. In enforced mode refuse a non-loopback bind (fail-closed); + # a real deployment must front it with TLS + auth (Plan-07 TIER 2), not 0.0.0.0. + _enforced = os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_DASHBOARD_STRICT") == "1" + if _enforced and BIND not in ("127.0.0.1", "::1", "localhost"): + sys.stderr.write(f"DASHBOARD_BIND_REFUSED bind={BIND} (loopback only in enforced mode)\n") + raise SystemExit(1) HTTPServer((BIND, PORT), Handler).serve_forever() diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/path-guard.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/path-guard.sh new file mode 100644 index 0000000..9718b00 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/path-guard.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-28 (X-04) — path-traversal / symlink guard. +# +# A tool that takes a file path as input/output can be pointed at an arbitrary +# location via `..` or a symlink (e.g. a symlink named "input.txt" -> /etc/passwd), +# reading or writing outside the workspace. This resolves the REAL path (following +# every symlink) and refuses anything that escapes the allowed root. +# +# Usage: path-guard.sh [allowed-root] (default root: repo workspace) +# Exit: 0 inside the root, 1 outside / unresolvable. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +TARGET="${1:-}" +ROOT="${2:-$PROJECT_ROOT}" +if [[ -z "$TARGET" ]]; then + echo "Usage: path-guard.sh [allowed-root]" >&2 + exit 64 +fi + +python3 - "$TARGET" "$ROOT" <<'PY' +import os +import sys + +target, root = sys.argv[1], sys.argv[2] +# realpath resolves symlinks in every existing path component and normalizes ".."; +# for a not-yet-created leaf it resolves the existing parent chain. +real_target = os.path.realpath(target) +real_root = os.path.realpath(root) + +if real_target == real_root or real_target.startswith(real_root + os.sep): + print(f"PATH_OK {real_target}") + sys.exit(0) +sys.stderr.write(f"PATH_ESCAPES_ROOT target={target} real={real_target} root={real_root}\n") +sys.exit(1) +PY diff --git a/AINative_OKR_CASAN5/.specify/scripts/bash/provider-usage-fetch.sh b/AINative_OKR_CASAN5/.specify/scripts/bash/provider-usage-fetch.sh index afb8be2..13ffc0a 100644 --- a/AINative_OKR_CASAN5/.specify/scripts/bash/provider-usage-fetch.sh +++ b/AINative_OKR_CASAN5/.specify/scripts/bash/provider-usage-fetch.sh @@ -24,6 +24,52 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" OUT="${2:-$PROJECT_ROOT/.specify/logs/level5/provider-usage.jsonl}" mkdir -p "$(dirname "$OUT")" +# SEC-13 (M-09): SSRF guard on the fetch URL. ALWAYS reject non-http(s) schemes +# (file://, gopher://, dict://, … metadata exfil). In enforced mode additionally +# require the host to be in the provider allowlist and block internal/link-local +# IPs — dev keeps loopback mocks working (http://127.0.0.1 test servers). +if ! python3 - "$API_URL" <<'PY' +import ipaddress, os, sys +from urllib.parse import urlparse + +url = sys.argv[1] +u = urlparse(url) +scheme = (u.scheme or "").lower() +host = (u.hostname or "").lower() +enforced = os.environ.get("CASAN_PROFILE") == "prod" or os.environ.get("CASAN_SSRF_STRICT") == "1" +allow = [h.strip().lower() for h in os.environ.get( + "CASAN_PROVIDER_HOST_ALLOWLIST", "api.openai.com,api.anthropic.com").split(",") if h.strip()] + +def die(reason): + sys.stderr.write(f"PROVIDER_URL_REJECTED {reason} url={url}\n") + sys.exit(1) + +if scheme not in ("http", "https"): + die(f"scheme_not_allowed:{scheme or 'none'}") # blocks file:// et al (all modes) +if not host: + die("no_host") + +if enforced: + if scheme != "https": + die("plaintext_http_not_allowed_in_prod") + if host in ("localhost",) or host.endswith(".internal") or host.endswith(".local"): + die(f"internal_host:{host}") + try: + ip = ipaddress.ip_address(host) + if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved + or ip.is_multicast or ip.is_unspecified): + die(f"internal_ip:{host}") + except ValueError: + pass # a hostname, not a literal IP + if allow and host not in allow: + die(f"host_not_in_allowlist:{host}") +sys.exit(0) +PY +then + echo "PROVIDER_API_SSRF_BLOCKED url=$API_URL (telemetry NOT imported)" >&2 + exit 1 +fi + BODY="$(mktemp)" trap 'rm -f "$BODY"' EXIT diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec13-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec13-tests.sh new file mode 100644 index 0000000..4f65402 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec13-tests.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-13 (M-09) — SSRF allowlist on outbound fetch + dashboard bind. +# +# provider-usage-fetch curled an arbitrary URL (SSRF: internal/link-local/file://), +# and the no-auth dashboard could bind 0.0.0.0. Proves: +# * file:// (and other non-http schemes) are ALWAYS rejected, +# * dev keeps loopback mocks working (http://127.0.0.1 → unreachable, not blocked), +# * enforced mode blocks loopback, metadata IPs, and non-allowlisted hosts, +# * the dashboard refuses a non-loopback bind in enforced mode. +# +# Deterministic; hermetic; no real network egress. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +S="$PROJECT_ROOT/.specify/scripts/bash" +FETCH="$S/provider-usage-fetch.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +OUT="$WORK/o.jsonl" + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +out_of() { set +e; "$@" 2>&1 >/dev/null; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-13: SSRF allowlist + dashboard bind =====" + +# 1) file:// always rejected (any mode). +out_of bash "$FETCH" "file:///etc/passwd" "$OUT" | grep -q "PROVIDER_API_SSRF_BLOCKED\|scheme_not_allowed" \ + && pass "file:// scheme rejected (all modes)" || fail "file:// not rejected" + +# 2) dev + loopback mock: NOT ssrf-blocked (reaches curl → unreachable). +O="$(out_of bash "$FETCH" "http://127.0.0.1:59991/usage" "$OUT")" +if echo "$O" | grep -q "PROVIDER_API_SSRF_BLOCKED"; then + fail "dev loopback mock wrongly SSRF-blocked (breaks provider telemetry test)" +else + pass "dev loopback mock allowed to reach fetch (backward compatible)" +fi + +# 3) enforced + loopback → blocked. +out_of env CASAN_SSRF_STRICT=1 bash "$FETCH" "https://127.0.0.1/usage" "$OUT" | grep -q "SSRF_BLOCKED\|internal_ip" \ + && pass "enforced: loopback blocked" || fail "enforced loopback not blocked" + +# 4) enforced + cloud metadata IP → blocked. +out_of env CASAN_SSRF_STRICT=1 bash "$FETCH" "https://169.254.169.254/latest/meta-data" "$OUT" | grep -q "SSRF_BLOCKED\|internal_ip" \ + && pass "enforced: link-local metadata IP blocked" || fail "metadata IP not blocked" + +# 5) enforced + non-allowlisted host → blocked. +out_of env CASAN_SSRF_STRICT=1 bash "$FETCH" "https://evil.example.com/usage" "$OUT" | grep -q "SSRF_BLOCKED\|host_not_in_allowlist" \ + && pass "enforced: non-allowlisted host blocked" || fail "non-allowlisted host not blocked" + +# 6) dashboard refuses non-loopback bind in enforced mode. +out_of env CASAN_DASHBOARD_STRICT=1 CASAN_DASHBOARD_BIND=0.0.0.0 CASAN_DASHBOARD_PORT=0 \ + python3 "$S/dashboard-server.py" | grep -q "DASHBOARD_BIND_REFUSED" \ + && pass "enforced: dashboard refuses 0.0.0.0 bind" || fail "dashboard allowed 0.0.0.0 in enforced mode" + +echo "" +echo "===== SEC-13 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec27-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec27-tests.sh new file mode 100644 index 0000000..2f3f030 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec27-tests.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-27 (X-02) — strip control/ANSI chars from log output. +# +# Log messages carry attacker-influenced data (action names, tool-output snippets). +# A raw ESC/CSI sequence can rewrite a reviewer's terminal; a raw CR/LF can inject a +# fake log line. casan_log now strips control chars (keeping tab). Proves the ESC +# byte and embedded newlines are removed while the visible text survives. +# +# Deterministic; no model/network. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } + +echo "===== Plan-16 SEC-27: log control-char stripping =====" + +# shellcheck source=/dev/null +source "$PROJECT_ROOT/.specify/scripts/bash/casan-log.sh" + +PAYLOAD="$(printf 'start\033[31mRED\033[0m\nFAKE [ERROR] injected-audit-line')" +OUT="$(casan_log error test "$PAYLOAD" 2>&1)" + +if printf '%s' "$OUT" | od -An -c | grep -q '033'; then + fail "ESC byte survived into the log (terminal-escape injection)" +else + pass "ESC byte stripped from log output" +fi + +LINES="$(printf '%s\n' "$OUT" | grep -c .)" +[[ "$LINES" -eq 1 ]] \ + && pass "embedded newline stripped — no injected second log line" \ + || fail "log emitted $LINES lines (newline injection)" + +printf '%s' "$OUT" | grep -q "start" && printf '%s' "$OUT" | grep -q "RED" \ + && pass "visible text preserved (only control bytes removed)" \ + || fail "visible text lost" + +echo "" +echo "===== SEC-27 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/phase-sec28-tests.sh b/AINative_OKR_CASAN5/.specify/tests/phase-sec28-tests.sh new file mode 100644 index 0000000..6159f12 --- /dev/null +++ b/AINative_OKR_CASAN5/.specify/tests/phase-sec28-tests.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -uo pipefail + +# CASAN Plan-16 SEC-28 (X-04) — path-traversal / symlink guard. +# +# path-guard.sh resolves the REAL path (following symlinks, normalizing "..") and +# refuses anything that escapes the allowed root — so a tool file argument cannot be +# a symlink to /etc/passwd or a ../.. escape. Proves in-root paths pass and escapes +# (via .. and via symlink) are rejected. +# +# Deterministic; hermetic. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +PG="$PROJECT_ROOT/.specify/scripts/bash/path-guard.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +PASS=0; FAIL=0 +pass() { echo "PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); } +rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; } + +echo "===== Plan-16 SEC-28: path-traversal / symlink guard =====" + +mkdir -p "$WORK/root/sub" +[[ "$(rc_of bash "$PG" "$WORK/root/sub/out.txt" "$WORK/root")" -eq 0 ]] \ + && pass "in-root path accepted" || fail "in-root path rejected" + +[[ "$(rc_of bash "$PG" "$WORK/root/../../etc/passwd" "$WORK/root")" -ne 0 ]] \ + && pass "'..' escape rejected" || fail "'..' escape accepted" + +# A symlink inside the root that points OUTSIDE it must be rejected. +ln -s /etc/passwd "$WORK/root/evil-link" +[[ "$(rc_of bash "$PG" "$WORK/root/evil-link" "$WORK/root")" -ne 0 ]] \ + && pass "symlink escaping root rejected (realpath resolves the target)" \ + || fail "symlink escape accepted" + +# A symlink that stays inside the root is fine. +echo hi > "$WORK/root/sub/real.txt" +ln -s "$WORK/root/sub/real.txt" "$WORK/root/ok-link" +[[ "$(rc_of bash "$PG" "$WORK/root/ok-link" "$WORK/root")" -eq 0 ]] \ + && pass "in-root symlink accepted" || fail "in-root symlink rejected" + +echo "" +echo "===== SEC-28 SUMMARY: PASS=$PASS FAIL=$FAIL =====" +[[ "$FAIL" -eq 0 ]] || exit 1 diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json index ab20156..8942e29 100644 --- a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json +++ b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json @@ -96,6 +96,10 @@ "sha256": "099c81a0a37b51137dbf328dbe2ef778226d0716b97660d083c1fcc6a4aecd19", "checks": 5 }, + "phase-sec13-tests.sh": { + "sha256": "64d67fa0ccf9c199848323e8fb6207f0cbc9608f6e256a0a7337b371c0451b43", + "checks": 6 + }, "phase-sec16-tests.sh": { "sha256": "3d826ca4f5c8f98837cc70846b8e2f2f83d5a598c2a5907795e8b9cc9db448c2", "checks": 6 @@ -116,6 +120,14 @@ "sha256": "303503fb3670be7d1b3c2737451eff64e7f140e940d2ae16188f686af0992dfa", "checks": 4 }, + "phase-sec27-tests.sh": { + "sha256": "1096f622ef010aecd44b9545c72f32a3b31009c3b250f4424d92018d74830e87", + "checks": 3 + }, + "phase-sec28-tests.sh": { + "sha256": "8f082c85b80bf899ea1911e8165e28c14f691e1ac3605953cd1b968ee295e556", + "checks": 4 + }, "phase-selfimprove-tests.sh": { "sha256": "e91db1af16e30b18def130553f27f05691ff5540eca0593ca5e07f624c0ef938", "checks": 7 @@ -161,6 +173,6 @@ "checks": 10 } }, - "total_checks": 343, - "suite_count": 40 + "total_checks": 356, + "suite_count": 43 } diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head index 22b79fc..d232a04 100644 --- a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head +++ b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.head @@ -1 +1 @@ -5a25c53485019236dc7383f714812f2ed455c09dde2316e7ce6a9f4a4c58a852 \ No newline at end of file +636416c4a4e4e7392bcb48b91f7d4b9ea93338c6f3feb469605215917d317e12 \ No newline at end of file diff --git a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig index f81c3b2..9ded1b0 100644 --- a/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig +++ b/AINative_OKR_CASAN5/.specify/tests/test-integrity-manifest.json.sig @@ -1 +1,4 @@ -ÞBÈÚß÷³îß‘wvíþ»È«4Øä4"÷.=nN; É'�b™A(ÔBÊdiSüRq±šñ3ý¥\VªÇÈp²ëÍw¬'ùwö-¦áškÅû¯XoÏ€ªRu­™ûn~øîŸ|ûC-O°}´ÙlíS% zÜÜjÛúGÜTæ`Še]VΙ ÿ¯­ùéä¯f¤³_}ó÷ëÕK¼t!þ<÷B q hä1ê¤Eýäì`z“iñ´whéñÜ?ÀÄÅ¿~ÄmCÇ/…ÓcÂáQ Ò¨�‘]¹¶sÝybþ„Ž„¥'ûÉ2õå4òZx?»^÷î_ªäÃWÖO \ No newline at end of file +Wã)´¦úê²>DéIÈš–&zbFÏãúÙZæ¬ç&,—¿¼åšÚ^·›;¦­ŒÛÉ’à�õßk’ïùo™"�Ç<¸ŸSTå'›CÈtª©ìóa©ä +È2+·Îµã4’Bl¨€“�ˆåfOŸ"y3—ã *N†Ú +x„Ëݱ�‹u…dó×èŽË“DY�¬³Ä{™0Þí¥Ì5ø‰xg§Â€ÿ½±»¿‡*môåËãñ ×n +k.Ö²MÓGD¨ƒ5§iÛ²à´2eHŽkINÞÖsÜJ®Î+°PhÏÛ¼xúª=<2y„{óe ŽØ·Ë¨hÐnâ‘Íä \ No newline at end of file