feat: update plan 16 sec14-26

This commit is contained in:
thanhnv
2026-07-07 15:46:36 +09:00
parent 0c60ed33e9
commit ae4fc7112c
64 changed files with 2231 additions and 116 deletions
@@ -24,7 +24,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
LOG_DIR="$PROJECT_ROOT/.specify/logs"
TRACE_DIR="$LOG_DIR/trace"
METRICS_DIR="$LOG_DIR/cost"
# SEC-23 (MT-01): telemetry dir is tenant-scoped when CASAN_METRICS_DIR is set
# (tenant-paths.sh exports it per tenant); default is the shared path.
METRICS_DIR="${CASAN_METRICS_DIR:-$LOG_DIR/cost}"
ALERT_LOG="$PROJECT_ROOT/.specify/agentops/alerts.log"
METRICS_LOG="$METRICS_DIR/metrics.jsonl"
mkdir -p "$TRACE_DIR" "$METRICS_DIR" "$(dirname "$OUTPUT_FILE")" "$(dirname "$ALERT_LOG")"
@@ -22,6 +22,9 @@ set -uo pipefail
# CASAN_APPROVAL_JWT (optional RS256 IdP token)
# CASAN_IDP_PUBLIC_KEY (default central-governance/idp-public.pem)
# CASAN_IDP_JWKS_URL (optional OIDC JWKS endpoint; overrides public key)
# CASAN_TRUSTED_TIME / CASAN_TRUSTED_TIME_FILE (SEC-22/ARCH-06: trusted time
# source for JWT `exp` instead of the manipulable local clock; file
# unreadable = fail-closed)
# Exit: 0 ok (prints "APPROVAL_OK role=<role>"), 3 deny (reason on stderr), 64 usage.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -109,7 +112,27 @@ header = json.loads(b64u_decode(parts[0]))
claims = json.loads(b64u_decode(parts[1]))
if header.get("alg") != "RS256":
die("jwt_alg_not_allowed")
if int(claims.get("exp", 0)) <= int(time.time()):
# SEC-22 (ARCH-06): do NOT trust the local system clock alone for expiry. When a
# trusted time source is provided (CASAN_TRUSTED_TIME seconds, or
# CASAN_TRUSTED_TIME_FILE containing seconds from a trusted timestamp authority),
# use it; an unreadable/invalid source is fail-closed (deny).
def _trusted_now():
v = os.environ.get("CASAN_TRUSTED_TIME")
if v:
try:
return int(v)
except Exception:
die("trusted_time_invalid")
f = os.environ.get("CASAN_TRUSTED_TIME_FILE")
if f:
try:
return int(open(f).read().strip())
except Exception:
die("trusted_time_file_unreadable")
return int(time.time())
if int(claims.get("exp", 0)) <= _trusted_now():
die("jwt_expired")
if claims.get("sub") != approver:
die("jwt_sub_mismatch")
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-25 (SC-07, offline) — build-artifact attestation (tested==deployed).
#
# A green CI gate proves the TESTED artifact is sound, but nothing binds it to what
# is DEPLOYED — a different artifact could ship. This produces a signed attestation
# over an artifact's content hash; verification recomputes the hash and checks the
# signature, so a swapped/modified artifact (deployed != tested) or a forged
# attestation is REFUSED (fail-closed). Offline form of SLSA-style provenance;
# real signed-commit enrollment + full provenance chain need CI/key infra.
#
# Usage:
# artifact-attest.sh attest <artifact> <priv-key> # -> <artifact>.att (+ .att.sig)
# artifact-attest.sh verify <artifact> <attestation> <pub> # tested==deployed check
# Exit: 0 ok · 2 mismatch/forged/tampered · 3 missing/unsigned/openssl · 64 usage.
CMD="${1:-}"; ART="${2:-}"
command -v openssl >/dev/null 2>&1 || { echo "OPENSSL_UNAVAILABLE" >&2; exit 3; }
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
else shasum -a 256 "$1" | awk '{print $1}'; fi
}
case "$CMD" in
attest)
KEY="${3:-}"
[[ -f "$ART" && -f "$KEY" ]] || { echo "usage: artifact-attest.sh attest <artifact> <priv-key>" >&2; exit 64; }
H="$(sha256_of "$ART")"
ATT="$ART.att"
printf '{"artifact":"%s","sha256":"%s","attested_at":"%s"}\n' \
"$(basename "$ART")" "$H" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$ATT"
openssl dgst -sha256 -sign "$KEY" -out "$ATT.sig" "$ATT" 2>/dev/null \
|| { echo "ATTEST_SIGN_FAILED" >&2; exit 2; }
echo "ARTIFACT_ATTESTED artifact=$(basename "$ART") sha256=${H:0:16}… att=$ATT"
exit 0
;;
verify)
ATT="${3:-}"; KEY="${4:-}"
[[ -f "$ART" ]] || { echo "ARTIFACT_MISSING file=$ART" >&2; exit 3; }
[[ -n "$ATT" && -f "$ATT" ]] || { echo "ATTESTATION_MISSING file=$ATT — refusing (fail-closed)" >&2; exit 3; }
[[ -f "$KEY" ]] || { echo "ATTEST_PUBKEY_MISSING key=$KEY" >&2; exit 3; }
[[ -f "$ATT.sig" ]] || { echo "ATTESTATION_UNSIGNED file=$ATT — refusing (fail-closed)" >&2; exit 3; }
if ! openssl dgst -sha256 -verify "$KEY" -signature "$ATT.sig" "$ATT" >/dev/null 2>&1; then
echo "ATTESTATION_FORGED file=$ATT — tampered or wrong key" >&2; exit 2
fi
WANT="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("sha256",""))' "$ATT" 2>/dev/null)"
HAVE="$(sha256_of "$ART")"
if [[ -z "$WANT" || "$WANT" != "$HAVE" ]]; then
echo "ARTIFACT_MISMATCH deployed!=tested want=${WANT:0:16}… have=${HAVE:0:16}…" >&2; exit 2
fi
echo "ARTIFACT_VERIFIED tested==deployed sha256=${HAVE:0:16}…"
exit 0
;;
*)
echo "Usage: artifact-attest.sh {attest <artifact> <priv>|verify <artifact> <att> <pub>}" >&2
exit 64
;;
esac
@@ -23,6 +23,11 @@ fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# SEC-23 (MT-01): make state (control-plane settings, telemetry, audit) tenant-scoped
# when CASAN_TENANT_ID is set, so a run for tenant A never touches tenant B's state.
# No-op when no tenant is set (baseline unchanged); invalid tenant fails closed.
# shellcheck source=tenant-paths.sh
source "$SCRIPT_DIR/tenant-paths.sh"
TMP_DIR="$PROJECT_ROOT/.specify/logs/tmp"
CACHE_DIR="$PROJECT_ROOT/.specify/logs/idempotency"
mkdir -p "$TMP_DIR" "$CACHE_DIR" "$(dirname "$FINAL_OUTPUT")"
@@ -95,6 +100,15 @@ if [[ "${CASAN_KILLSWITCH_ENFORCE:-0}" == "1" || ( -z "${CASAN_KILLSWITCH_ENFORC
echo "KILL_SWITCH_ACTIVE scope=$KS_SCOPE id=$KS_ID action=$ACTION_NAME" >&2
exit 2
fi
# SEC-23 (MT-03): a tenant-scoped switch halts ONLY its own tenant (noisy-neighbor
# isolation) — tenant A's emergency stop must not stop tenant B.
if [[ -n "${CASAN_TENANT_ID:-}" ]] \
&& ! bash "$SCRIPT_DIR/kill-switch.sh" check tenant "$CASAN_TENANT_ID" >/dev/null 2>&1; then
casan_log error harness "KILL_SWITCH_ACTIVE scope=tenant id=$CASAN_TENANT_ID — refusing to run $ACTION_NAME"
: > "$FINAL_OUTPUT"
echo "KILL_SWITCH_ACTIVE scope=tenant id=$CASAN_TENANT_ID action=$ACTION_NAME" >&2
exit 2
fi
fi
# SEC-16 (ARCH-01): in enforced mode, verify the harness+policy bundle against its
@@ -81,6 +81,7 @@ run "phase10-traceability" bash "$TESTS/phase10-traceability-tests.sh"
run "phase08-compression" bash "$TESTS/phase08-compression-tests.sh"
run "phase-control-plane" bash "$TESTS/phase-control-plane-tests.sh"
run "phase-rbac" bash "$TESTS/phase-rbac-tests.sh"
run "phase-rbac-audit" bash "$TESTS/phase-rbac-audit-tests.sh"
run "phase-rai" bash "$TESTS/phase-rai-tests.sh"
run "phase-selfimprove" bash "$TESTS/phase-selfimprove-tests.sh"
run "phase-governance-report" bash "$TESTS/phase-governance-report-tests.sh"
@@ -106,12 +107,24 @@ 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-sec14-model-digest" bash "$TESTS/phase-sec14-tests.sh"
run "phase-sec27-log-controlchar" bash "$TESTS/phase-sec27-tests.sh"
run "phase-sec28-path-traversal" bash "$TESTS/phase-sec28-tests.sh"
run "phase-sec26-stored-inject" bash "$TESTS/phase-sec26-tests.sh"
run "phase-sec22-trusted-time" bash "$TESTS/phase-sec22-tests.sh"
run "phase-sec12-drift-invariant" bash "$TESTS/phase-sec12-tests.sh"
run "phase-sec29-audit-failclosed" bash "$TESTS/phase-sec29-tests.sh"
run "phase-sec30-approval-replay" bash "$TESTS/phase-sec30-tests.sh"
run "phase-sec15-low-cluster" bash "$TESTS/phase-sec15-tests.sh"
# Plan-16 SEC-23 (multi-tenant partition) — phased
run "phase-sec23-tenant-store" bash "$TESTS/phase-sec23-tenant-store-tests.sh"
run "phase-sec23-state-isolation" bash "$TESTS/phase-sec23-state-isolation-tests.sh"
run "phase-sec23-rbac-tenant" bash "$TESTS/phase-sec23-rbac-tenant-tests.sh"
run "phase-sec23-scope" bash "$TESTS/phase-sec23-scope-tests.sh"
run "phase-sec23-registry-crypt" bash "$TESTS/phase-sec23-registry-crypt-tests.sh"
# Plan-16 SEC-24/25 supply-chain (offline slice)
run "phase-sec24-supplychain" bash "$TESTS/phase-sec24-tests.sh"
run "phase-sec25-attestation" bash "$TESTS/phase-sec25-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).
@@ -44,11 +44,31 @@ def project_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
def _tenant_id():
# SEC-23 (MT-01): when a tenant id is set, the settings store (and its embedded
# audit hash-chain) is partitioned per tenant so tenant A cannot read/modify
# tenant B's governance state. An invalid id fails closed.
import re
t = os.environ.get("CASAN_TENANT_ID", "").strip()
if not t:
return None
if not re.fullmatch(r"[A-Za-z0-9_-]+", t):
raise SystemExit("CP_DENY tenant_id_invalid")
return t
def store_path() -> str:
return os.environ.get(
"CASAN_CP_STORE_FILE",
os.path.join(project_root(), ".specify/level5/control-plane-settings.json"),
)
explicit = os.environ.get("CASAN_CP_STORE_FILE")
if explicit:
return explicit
tenant = _tenant_id()
if tenant:
base = os.environ.get(
"CASAN_TENANT_STATE_ROOT",
os.path.join(project_root(), ".specify/state/tenants"),
)
return os.path.join(base, tenant, "control-plane", "settings.json")
return os.path.join(project_root(), ".specify/level5/control-plane-settings.json")
def now_iso() -> str:
@@ -20,7 +20,17 @@ set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
LOG="${1:-$PROJECT_ROOT/.specify/logs/level5/provider-usage.jsonl}"
# SEC-23 (MT-03): per-tenant cost/quota. With no explicit log arg, a tenant run
# evaluates its OWN usage log so one tenant's spend never counts against another's
# budget (noisy-neighbor isolation). No tenant set -> the shared default log.
if [[ -n "${1:-}" ]]; then
LOG="$1"
elif [[ -n "${CASAN_TENANT_ID:-}" ]]; then
LOG="$(bash "$SCRIPT_DIR/tenant-store.sh" resolve telemetry/provider-usage.jsonl 2>/dev/null)" \
|| { echo "COST_SPIKE_TENANT_DENIED" >&2; exit 3; }
else
LOG="$PROJECT_ROOT/.specify/logs/level5/provider-usage.jsonl"
fi
MULT="${2:-3.0}"
[[ -f "$LOG" ]] || { echo "COST_SPIKE_NO_DATA file=$LOG" >&2; exit 3; }
@@ -12,7 +12,8 @@ set -uo pipefail
# kill-switch.sh clear <scope> <id> [reason] # turn it OFF (audited)
# kill-switch.sh check <scope> <id> # exit 2 if engaged, 0 if clear
# kill-switch.sh status # list engaged switches
# scope ∈ {project, model, provider, global}. A `global` switch stops everything.
# scope ∈ {project, model, provider, tenant, global}. A `global` switch stops
# everything; a `tenant` switch (SEC-23 MT-03) stops only that tenant.
# Env: CASAN_KILLSWITCH_DIR (default .specify/logs/level5/kill-switch)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -29,8 +29,18 @@ MODEL="${2:-${CASAN_MODEL:-ornith:9b}}"
OLLAMA="${OLLAMA_HOST:-127.0.0.1:11434}"
current_digest() {
# 1) explicit override (deterministic for CI/tests)
if [[ -n "${CASAN_MODEL_DIGEST:-}" ]]; then printf '%s' "$CASAN_MODEL_DIGEST"; return 0; fi
# 1) explicit override (deterministic for CI/tests) — DISABLED in enforced mode.
# SEC-14 / M-03 / SC-03: an attacker who swaps the local model could also set
# CASAN_MODEL_DIGEST to the pinned value and defeat the check. Under
# CASAN_PROFILE=prod (or CASAN_MODEL_DIGEST_STRICT=1) the override is never
# trusted — the digest must come from the live model backend.
if [[ -n "${CASAN_MODEL_DIGEST:-}" ]]; then
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_MODEL_DIGEST_STRICT:-}" == "1" ]]; then
echo "MODEL_DIGEST_OVERRIDE_IGNORED enforced mode ignores CASAN_MODEL_DIGEST; using live digest" >&2
else
printf '%s' "$CASAN_MODEL_DIGEST"; return 0
fi
fi
# 2) live Ollama
local d
d="$(curl -sf "http://$OLLAMA/api/tags" 2>/dev/null | \
@@ -37,7 +37,12 @@ CLAIM_ROLE_MAP = {
}
def decide(role, resource, action, role_project, target_project, sensitive):
def decide(role, resource, action, role_project, target_project, sensitive,
role_tenant="", target_tenant=""):
# SEC-23 (MT-01): tenant isolation is enforced at the DATA layer BEFORE any role
# grant — even an org-admin of tenant A may not act on tenant B's resources.
if (role_tenant or target_tenant) and role_tenant != target_tenant:
return False, f"CROSS_TENANT_DENY tenant={role_tenant or 'none'}!={target_tenant or 'none'}"
perm = PERMISSIONS.get(role)
if perm is None:
return False, f"UNKNOWN_ROLE {role}"
@@ -66,6 +71,37 @@ def decide(role, resource, action, role_project, target_project, sensitive):
return True, f"ALLOW {role} {action_key}"
def _audit_decision(args, verdict, reason):
"""Plan-14: write each RBAC decision to an H5-style oversight log (opt-in via
CASAN_RBAC_AUDIT_LOG). Append-only; feeds the RAI/Control-Plane oversight view.
Off by default so existing flows are unchanged."""
import datetime
import json
import os
path = os.environ.get("CASAN_RBAC_AUDIT_LOG")
if not path:
return
rec = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"harness": "H5-rbac",
"role": args.role,
"resource": args.resource,
"action": f"{args.resource}:{args.action}",
"role_tenant": args.role_tenant or None,
"target_tenant": args.target_tenant or None,
"verdict": verdict,
"reason": reason,
}
try:
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
with open(path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
except OSError:
pass
def main() -> int:
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
@@ -76,6 +112,8 @@ def main() -> int:
c.add_argument("--action", required=True)
c.add_argument("--role-project", default="")
c.add_argument("--target-project", default="")
c.add_argument("--role-tenant", default="")
c.add_argument("--target-tenant", default="")
c.add_argument("--sensitive", action="store_true")
s = sub.add_parser("check-sod")
@@ -110,8 +148,10 @@ def main() -> int:
return 0
allowed, reason = decide(
args.role, args.resource, args.action, args.role_project, args.target_project, args.sensitive
args.role, args.resource, args.action, args.role_project, args.target_project,
args.sensitive, args.role_tenant, args.target_tenant,
)
_audit_decision(args, "ALLOW" if allowed else "DENY", reason)
if allowed:
print(f"RBAC_ALLOW {reason}")
return 0
@@ -62,6 +62,24 @@ def build_proposals(metrics_rows, drift):
return proposals
def verify_metrics_integrity(path, sig, pub):
"""True only if the metrics file has a valid detached signature (openssl).
ARCH-08: proposals from telemetry that is not integrity-verified are marked
untrusted so a reviewer (and the apply gate) treats them with suspicion."""
if not (path and sig and pub):
return False
if not (os.path.isfile(path) and os.path.isfile(sig) and os.path.isfile(pub)):
return False
try:
r = subprocess.run(
["openssl", "dgst", "-sha256", "-verify", pub, "-signature", sig, path],
capture_output=True,
)
return r.returncode == 0
except Exception:
return False
def cmd_propose(args):
metrics = read_jsonl(args.metrics)
drift = None
@@ -70,8 +88,15 @@ def cmd_propose(args):
drift = json.load(open(args.drift, encoding="utf-8"))
except ValueError:
drift = None
# ARCH-08 telemetry-poisoning defence: tag every proposal with the trust level
# of its source telemetry. Unsigned/unverifiable metrics -> untrusted.
trusted = verify_metrics_integrity(args.metrics, args.metrics_sig, args.metrics_pub)
source_trust = "verified" if trusted else "untrusted"
proposals = build_proposals(metrics, drift)
print(json.dumps({"proposals": proposals, "count": len(proposals)}, ensure_ascii=False, indent=2))
for p in proposals:
p["source_trust"] = source_trust
print(json.dumps({"proposals": proposals, "count": len(proposals),
"source_trust": source_trust}, ensure_ascii=False, indent=2))
return 0
@@ -86,6 +111,19 @@ def cmd_apply(args):
print(f"IMPROVE_DENY UNKNOWN_PROPOSAL {args.id}", file=sys.stderr)
return 1
# ARCH-08: in enforced mode (prod / CASAN_SELFIMPROVE_STRICT=1) refuse to apply a
# proposal derived from unverified telemetry unless explicitly allowed with
# justification. Dev default only tags (backward compatible). Missing tag =
# untrusted (fail-closed).
enforced = (os.environ.get("CASAN_PROFILE") == "prod"
or os.environ.get("CASAN_SELFIMPROVE_STRICT") == "1")
if (proposal.get("source_trust", "untrusted") == "untrusted"
and enforced and not args.allow_untrusted):
print(f"IMPROVE_DENY UNTRUSTED_SOURCE {args.id} (telemetry not integrity-verified; "
f"re-run propose with --metrics-sig/--metrics-pub, or pass --allow-untrusted)",
file=sys.stderr)
return 1
# Proposal != application: applying ALWAYS requires human approval (Plan-04).
if not (args.approval or "").strip():
print(f"IMPROVE_DENY APPROVAL_REQUIRED {args.id}", file=sys.stderr)
@@ -117,10 +155,13 @@ def main() -> int:
pr = sub.add_parser("propose")
pr.add_argument("--metrics", default="")
pr.add_argument("--drift", default="")
pr.add_argument("--metrics-sig", default="")
pr.add_argument("--metrics-pub", default="")
ap_ = sub.add_parser("apply")
ap_.add_argument("--proposals", required=True)
ap_.add_argument("--id", required=True)
ap_.add_argument("--approval", default="")
ap_.add_argument("--allow-untrusted", action="store_true")
args = ap.parse_args()
if args.cmd == "propose":
return cmd_propose(args)
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-26 (X-01) — stored / second-order injection scan.
#
# "Trusted" data that later flows INTO a prompt — golden-runs, red-team corpus,
# traceability map, requirement docs — was never H4-scanned, because the gate only
# scanned DIRECT input. A payload planted in such a file becomes a stored injection
# the moment that file is loaded into the model context on a later step (the
# stored-XSS analog). This scans every such source with the SAME H4 layer
# (artifact-scan.sh → security-check.sh input mode) BEFORE it may enter a prompt,
# and BLOCKS on any hit. Fail-closed: a required source that is missing/unreadable,
# or any scan error, is treated as BLOCK (not silently skipped).
#
# Usage: stored-content-scan.sh <path> [<path> ...]
# <path> = file or directory (directories scanned recursively; text files only).
# Exit: 0 all clean · 2 injection detected OR a required source missing/unreadable · 64 usage.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ARTIFACT_SCAN="$SCRIPT_DIR/artifact-scan.sh"
[[ "$#" -ge 1 ]] || { echo "Usage: stored-content-scan.sh <path> [<path> ...]" >&2; exit 64; }
TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
scanned=0; blocked=0; missing=0
scan_file() {
local f="$1"
scanned=$((scanned + 1))
if ! bash "$ARTIFACT_SCAN" "$f" "stored:$f" >/dev/null 2>&1; then
echo "STORED_SCAN_BLOCKED file=$f reason=injection_or_scan_error timestamp=$TS" >&2
blocked=$((blocked + 1))
fi
}
for p in "$@"; do
if [[ -f "$p" ]]; then
scan_file "$p"
elif [[ -d "$p" ]]; then
# Recurse; scan text files only (grep -I skips binaries), ignore VCS metadata.
while IFS= read -r -d '' f; do
grep -Iq . "$f" 2>/dev/null && scan_file "$f"
done < <(find "$p" -type f -not -path '*/.git/*' -print0 2>/dev/null)
else
echo "STORED_SCAN_MISSING path=$p (required source absent/unreadable) timestamp=$TS" >&2
missing=$((missing + 1))
fi
done
if [[ "$blocked" -gt 0 || "$missing" -gt 0 ]]; then
echo "STORED_SCAN_RESULT scanned=$scanned blocked=$blocked missing=$missing verdict=BLOCK timestamp=$TS" >&2
exit 2
fi
echo "STORED_SCAN_RESULT scanned=$scanned blocked=0 missing=0 verdict=CLEAN timestamp=$TS"
exit 0
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-24 (SC-05/06, offline) — supply-chain integrity for build files.
#
# Two harness-side controls that need no network:
# * image-pin: Dockerfiles / CI workflow files must pin container images by DIGEST
# (`@sha256:...`), never a floating tag (`:latest`, `:20`, or no tag) — a floating
# tag lets a malicious image be swapped in under the same name.
# * sign / verify: a CI workflow (or any build file) is signed and verified on load;
# a tampered, forged, or UNSIGNED file is REFUSED (fail-closed).
#
# (Live CVE/OSV scanning and real image scanning need infra and remain planned.)
#
# Usage:
# supply-chain-integrity.sh image-pin <file> [<file> ...]
# supply-chain-integrity.sh sign <file> <priv-key>
# supply-chain-integrity.sh verify <file> <pub-key>
# Exit: 0 ok · 2 violation (unpinned image / tampered-forged sig) · 3 missing/unsigned/openssl · 64 usage.
CMD="${1:-}"; shift || true
case "$CMD" in
image-pin)
[[ "$#" -ge 1 ]] || { echo "usage: supply-chain-integrity.sh image-pin <file>..." >&2; exit 64; }
for f in "$@"; do [[ -f "$f" ]] || { echo "IMAGE_PIN_MISSING file=$f" >&2; exit 3; }; done
python3 - "$@" <<'PY'
import re, sys
bad = []
# image refs from Dockerfile `FROM x` and workflow/compose `image: x`
pat = re.compile(r'^\s*(?:FROM\s+|image:\s*["\']?)([^\s"\']+)', re.IGNORECASE)
for path in sys.argv[1:]:
for i, line in enumerate(open(path, encoding="utf-8", errors="replace"), 1):
m = pat.match(line)
if not m:
continue
ref = m.group(1).strip()
low = ref.lower()
if low in ("scratch",):
continue
# bare single token with no registry path and no tag = local build stage -> ok
if "/" not in ref and ":" not in ref and "." not in ref and "@" not in ref:
continue
if "@sha256:" in ref:
continue # digest-pinned -> ok
bad.append(f"{path}:{i} unpinned image '{ref}' (use @sha256:<digest>)")
if bad:
for b in bad:
sys.stderr.write("IMAGE_UNPINNED " + b + "\n")
raise SystemExit(2)
print("IMAGE_PIN_OK all images digest-pinned")
PY
;;
sign)
F="${1:-}"; KEY="${2:-}"
command -v openssl >/dev/null 2>&1 || { echo "OPENSSL_UNAVAILABLE" >&2; exit 3; }
[[ -f "$F" && -f "$KEY" ]] || { echo "usage: supply-chain-integrity.sh sign <file> <priv-key>" >&2; exit 64; }
openssl dgst -sha256 -sign "$KEY" -out "$F.sig" "$F" 2>/dev/null \
&& { echo "WORKFLOW_SIGNED file=$F"; exit 0; }
echo "WORKFLOW_SIGN_FAILED file=$F" >&2; exit 2
;;
verify)
F="${1:-}"; KEY="${2:-}"
command -v openssl >/dev/null 2>&1 || { echo "OPENSSL_UNAVAILABLE" >&2; exit 3; }
[[ -f "$F" ]] || { echo "WORKFLOW_MISSING file=$F" >&2; exit 3; }
[[ -f "$KEY" ]] || { echo "WORKFLOW_PUBKEY_MISSING key=$KEY" >&2; exit 3; }
[[ -f "$F.sig" ]] || { echo "WORKFLOW_UNSIGNED file=$F — refusing (fail-closed)" >&2; exit 3; }
if openssl dgst -sha256 -verify "$KEY" -signature "$F.sig" "$F" >/dev/null 2>&1; then
echo "WORKFLOW_VERIFIED file=$F"; exit 0
fi
echo "WORKFLOW_INVALID file=$F — tampered or forged" >&2; exit 2
;;
*)
echo "Usage: supply-chain-integrity.sh {image-pin <file>...|sign <file> <priv>|verify <file> <pub>}" >&2
exit 64
;;
esac
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-23 (23.10, MT-02) — per-tenant encryption at rest (local-key MVP).
#
# Sensitive state (audit / telemetry) is encrypted with a PER-TENANT key so tenant B
# — or an admin of B — cannot read tenant A's plaintext on disk. The key lives under
# the tenant partition (0600) and differs per tenant, so a ciphertext produced by A
# cannot be decrypted with B's key. Production form uses Vault Transit (23.11, needs
# infra); this is the offline form.
#
# Usage:
# tenant-crypt.sh encrypt <plaintext-file> <ciphertext-file>
# tenant-crypt.sh decrypt <ciphertext-file> <plaintext-file>
# Env: CASAN_TENANT_ID (key is tenant-specific; prod requires it — tenant-store).
# Exit: 0 ok · 2 crypto failure (e.g. wrong tenant key) · 3 tenant/key/openssl error · 64 usage.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TS="$SCRIPT_DIR/tenant-store.sh"
CMD="${1:-}"; IN="${2:-}"; OUT="${3:-}"
command -v openssl >/dev/null 2>&1 || { echo "TENANT_CRYPT_OPENSSL_UNAVAILABLE" >&2; exit 3; }
[[ -n "$CMD" && -n "$IN" && -n "$OUT" ]] || { echo "usage: tenant-crypt.sh {encrypt|decrypt} <in> <out>" >&2; exit 64; }
[[ -f "$IN" ]] || { echo "TENANT_CRYPT_INPUT_MISSING file=$IN" >&2; exit 3; }
# Per-tenant key (created once, 0600). tenant-store fails closed on an invalid/missing
# tenant in prod; in dev it resolves under the 'default' tenant.
KEYFILE="$(bash "$TS" resolve keys/at-rest.key 2>/dev/null)" || { echo "TENANT_CRYPT_DENIED (tenant unresolved)" >&2; exit 3; }
if [[ ! -f "$KEYFILE" ]]; then
openssl rand -base64 48 > "$KEYFILE" 2>/dev/null || { echo "TENANT_CRYPT_KEYGEN_FAILED" >&2; exit 3; }
chmod 600 "$KEYFILE" 2>/dev/null || true
fi
case "$CMD" in
encrypt)
if openssl enc -aes-256-cbc -pbkdf2 -salt -in "$IN" -out "$OUT" -pass "file:$KEYFILE" 2>/dev/null; then
echo "TENANT_ENCRYPTED out=$OUT"
exit 0
fi
echo "TENANT_ENCRYPT_FAILED" >&2; exit 2
;;
decrypt)
if openssl enc -d -aes-256-cbc -pbkdf2 -in "$IN" -out "$OUT" -pass "file:$KEYFILE" 2>/dev/null; then
echo "TENANT_DECRYPTED out=$OUT"
exit 0
fi
rm -f "$OUT" 2>/dev/null || true
echo "TENANT_DECRYPT_FAILED (wrong tenant key or corrupt ciphertext)" >&2; exit 2
;;
*)
echo "Usage: tenant-crypt.sh {encrypt|decrypt} <in> <out>" >&2
exit 64
;;
esac
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# CASAN Plan-16 SEC-23 (MT-01) — tenant-scoped state path resolver. SOURCE this.
#
# When CASAN_TENANT_ID is set, exports per-tenant locations for state that would
# otherwise be global (control-plane settings + its audit chain, telemetry, audit),
# so each subsystem writes under its own tenant partition. Each var is only set if
# not already overridden (explicit env wins). An invalid tenant id fails CLOSED.
#
# Usage: source tenant-paths.sh
# (control-plane-settings.py is tenant-aware on its own via CASAN_TENANT_ID; this
# resolver covers the bash subsystems that read path env vars.)
_CASAN_TP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
_CASAN_TS="$_CASAN_TP_DIR/tenant-store.sh"
_casan_tp_set() { # <var-name> <logical-name>
local var="$1" name="$2" path
[[ -n "${!var:-}" ]] && return 0 # explicit override wins
path="$(bash "$_CASAN_TS" resolve "$name" 2>/dev/null)" || return 1
export "$var=$path"
}
if [[ -n "${CASAN_TENANT_ID:-}" ]]; then
if ! bash "$_CASAN_TS" id >/dev/null 2>&1; then
echo "TENANT_PATHS_DENIED invalid or missing tenant id" >&2
return 1 2>/dev/null || exit 1
fi
_casan_tp_set CASAN_CP_STORE_FILE control-plane/settings.json
_casan_tp_set CASAN_CP_KEY_DIR control-plane/keys
_casan_tp_set CASAN_METRICS_DIR telemetry/cost
_casan_tp_set CASAN_TENANT_AUDIT_DIR audit
fi
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-23 (23.9, MT-04) — sign + verify the tenant/project registry.
#
# An UNSIGNED registry lets an attacker register a fake tenant or swap another
# tenant's project mapping (bypassing every tenant boundary that trusts it). This
# signs the registry (detached signature) and verifies it on load. A tampered,
# forged (wrong key), or unsigned registry is REFUSED — fail-closed by default.
#
# Usage:
# tenant-registry-verify.sh sign <registry-file> <priv-key>
# tenant-registry-verify.sh verify <registry-file> <pub-key>
# Exit: 0 ok · 2 invalid/tampered/forged · 3 missing signature/file/openssl · 64 usage.
# (Production anchors the signature in KMS — see SEC-02; this is the offline form.)
CMD="${1:-}"; REG="${2:-}"; KEY="${3:-}"
command -v openssl >/dev/null 2>&1 || { echo "REGISTRY_OPENSSL_UNAVAILABLE" >&2; exit 3; }
case "$CMD" in
sign)
[[ -f "$REG" && -f "$KEY" ]] || { echo "usage: tenant-registry-verify.sh sign <registry> <priv-key>" >&2; exit 64; }
if openssl dgst -sha256 -sign "$KEY" -out "$REG.sig" "$REG" 2>/dev/null; then
echo "REGISTRY_SIGNED file=$REG sig=$REG.sig"
exit 0
fi
echo "REGISTRY_SIGN_FAILED file=$REG" >&2; exit 2
;;
verify)
[[ -f "$REG" ]] || { echo "REGISTRY_MISSING file=$REG" >&2; exit 3; }
[[ -f "$KEY" ]] || { echo "REGISTRY_PUBKEY_MISSING key=$KEY" >&2; exit 3; }
if [[ ! -f "$REG.sig" ]]; then
# SEC-23/SEC-01: unsigned registry is REFUSED (fail-closed), not trusted.
echo "REGISTRY_UNSIGNED file=$REG — refusing (fail-closed)" >&2; exit 3
fi
if openssl dgst -sha256 -verify "$KEY" -signature "$REG.sig" "$REG" >/dev/null 2>&1; then
echo "REGISTRY_VERIFIED file=$REG"
exit 0
fi
echo "REGISTRY_INVALID file=$REG — tampered or forged signature" >&2; exit 2
;;
*)
echo "Usage: tenant-registry-verify.sh {sign|verify} <registry-file> <key>" >&2
exit 64
;;
esac
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -uo pipefail
# CASAN Plan-16 SEC-23 (MT-01) — tenant-partitioned state store + cross-tenant guard.
#
# Harness state (audit chains, control-plane settings, telemetry, logs, kill-switch)
# used to live in SHARED global files, so a run for tenant A could read/modify
# tenant B's state directly — bypassing RBAC (which only guarded the API, not the
# files). This resolves every state path under a per-tenant root and refuses any
# access that escapes the caller's own tenant partition (reusing the SEC-28 realpath
# guard). Deny-by-default; secure-by-default in prod (missing tenant = fail-closed).
#
# Tenant id: CASAN_TENANT_ID (allowlist [A-Za-z0-9_-]+, no path traversal). Unset →
# 'default' in dev, but REFUSED under CASAN_PROFILE=prod.
#
# Usage:
# tenant-store.sh id # print resolved tenant id
# tenant-store.sh root # print this tenant's state root (0700)
# tenant-store.sh init # create tenant root with 0700 perms
# tenant-store.sh resolve <logical-name> # print tenant-scoped path for a state file
# tenant-store.sh guard <path> # exit 0 if path is inside own tenant root, else DENY
# Env: CASAN_TENANT_STATE_ROOT (default .specify/state/tenants)
# Exit: 0 ok · 3 denied (reason on stderr) · 64 usage.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
STATE_ROOT="${CASAN_TENANT_STATE_ROOT:-$PROJECT_ROOT/.specify/state/tenants}"
die() { echo "TENANT_DENIED reason=$1" >&2; exit 3; }
resolve_tenant_id() {
local t="${CASAN_TENANT_ID:-}"
if [[ -z "$t" ]]; then
[[ "${CASAN_PROFILE:-}" == "prod" ]] && die "tenant_id_required_in_prod"
t="default"
fi
[[ "$t" =~ ^[A-Za-z0-9_-]+$ ]] || die "tenant_id_invalid(${t})"
printf '%s' "$t"
}
tenant_root() {
local t; t="$(resolve_tenant_id)" || exit 3
printf '%s/%s' "$STATE_ROOT" "$t"
}
ensure_root() {
local root="$1"
mkdir -p "$root" || die "mkdir_failed(${root})"
chmod 700 "$STATE_ROOT" 2>/dev/null || true
chmod 700 "$root" 2>/dev/null || true
}
CMD="${1:-}"; shift || true
case "$CMD" in
id)
resolve_tenant_id; echo
;;
root)
tenant_root; echo
;;
init)
root="$(tenant_root)" || exit 3
ensure_root "$root"
echo "$root"
;;
resolve)
name="${1:-}"; [[ -n "$name" ]] || die "logical_name_required"
case "$name" in
/*|*..*) die "logical_name_invalid(${name})" ;;
esac
root="$(tenant_root)" || exit 3
ensure_root "$root"
mkdir -p "$(dirname "$root/$name")" 2>/dev/null || true
printf '%s/%s\n' "$root" "$name"
;;
guard)
target="${1:-}"; [[ -n "$target" ]] || die "target_required"
root="$(tenant_root)" || exit 3
ensure_root "$root"
if bash "$SCRIPT_DIR/path-guard.sh" "$target" "$root" >/dev/null 2>&1; then
echo "TENANT_OK path within own tenant root"
exit 0
fi
die "cross_tenant_access(target=${target})"
;;
*)
echo "Usage: tenant-store.sh {id|root|init|resolve <name>|guard <path>}" >&2
exit 64
;;
esac