feat: plan 16-01

This commit is contained in:
thanhnv
2026-07-06 21:47:38 +09:00
parent 4419cd9eae
commit 8c3c5e8bff
30 changed files with 1646 additions and 66 deletions
@@ -150,27 +150,37 @@ RECORD_CORE="$(printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s' "$TIMESTAMP" "$TRACE_ID
RECORD_HASH="$(printf '%s' "$RECORD_CORE" | hash_text)"
TRACE_FILE="$TRACE_DIR/governance-$TRACE_ID.json"
cat > "$TRACE_FILE" <<EOF
{
"trace_id": "$TRACE_ID",
"timestamp": "$TIMESTAMP",
"harness": "H5-governance",
"action": "$ACTION_NAME",
"actor": "$ACTOR",
"risk_level": "$RISK_LEVEL",
"decision": "$DECISION",
"approval_status": "$APPROVAL_STATUS",
"approver": "$APPROVER",
"reasons": $REASONS_JSON,
"input_hash": "$INPUT_HASH",
"output_hash": "$OUTPUT_HASH",
"previous_record_hash": "$PREV_HASH",
"record_hash": "$RECORD_HASH"
# SEC-05 (H-04): serialize both the standalone trace file and the appended audit
# chain line via json.dumps. Previously ACTOR/APPROVER/ACTION_NAME were interpolated
# raw, so a value containing `"` + newline could inject a SECOND forged audit record
# (a fabricated "approved" decision). The record_hash is still computed from
# RECORD_CORE above, so verify-audit-chain.sh recomputes and matches unchanged.
CASAN_GC_REASONS="$REASONS_JSON" python - "$TRACE_FILE" "$AUDIT_LOG" \
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" \
"$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" <<'PY'
import json, os, sys
(trace_file, audit_log, ts, trace_id, action, actor, risk, decision,
approval_status, approver, input_hash, output_hash, prev_hash, record_hash) = sys.argv[1:]
try:
reasons = json.loads(os.environ.get("CASAN_GC_REASONS") or "[]")
except ValueError:
reasons = []
rec = {
"timestamp": ts, "trace_id": trace_id, "harness": "H5-governance",
"action": action, "actor": actor, "risk_level": risk, "decision": decision,
"approval_status": approval_status, "approver": approver,
"input_hash": input_hash, "output_hash": output_hash,
"previous_record_hash": prev_hash, "record_hash": record_hash,
}
EOF
printf '{"timestamp":"%s","trace_id":"%s","harness":"H5-governance","action":"%s","actor":"%s","risk_level":"%s","decision":"%s","approval_status":"%s","approver":"%s","input_hash":"%s","output_hash":"%s","previous_record_hash":"%s","record_hash":"%s"}\n' \
"$TIMESTAMP" "$TRACE_ID" "$ACTION_NAME" "$ACTOR" "$RISK_LEVEL" "$DECISION" "$APPROVAL_STATUS" "$APPROVER" "$INPUT_HASH" "$OUTPUT_HASH" "$PREV_HASH" "$RECORD_HASH" >> "$AUDIT_LOG"
trace = {**rec, "reasons": reasons}
with open(trace_file, "w", encoding="utf-8") as f:
json.dump(trace, f, indent=2)
f.write("\n")
with open(audit_log, "a", encoding="utf-8") as f:
# Compact separators: the chain line is regex-parsed elsewhere and must match
# the original printf format (no space after ':' / ',').
f.write(json.dumps(rec, separators=(",", ":")) + "\n")
PY
# --- External anchor: cryptographically sign the new chain head ---
# A re-forged chain (recomputed hashes) changes the head; without the private
@@ -186,16 +196,27 @@ if command -v openssl >/dev/null 2>&1; then
AUDIT_PUB="$PUB_DIR/audit-public.pem"
mkdir -p "$PUB_DIR" "$PRIV_DIR"
if [[ ! -f "$AUDIT_PRIV" ]]; then
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null
chmod 600 "$AUDIT_PRIV"
if [[ "${CASAN_PROFILE:-}" == "prod" || "${CASAN_VERIFY_STRICT:-}" == "1" ]]; then
# SEC-02 (H-02): in enforced mode NEVER auto-generate a local signing key.
# A freshly-minted key next to the data lets any file-writer re-sign a forged
# head. Prod must provision the key out-of-band (KMS/HSM — see sign-audit-head.sh
# Vault path). With no key we skip signing; the head stays unsigned and SEC-01
# strict verification then FAILS CLOSED.
echo "AUDIT_SIGN_SKIPPED_ENFORCED no off-repo/KMS key provisioned; head left unsigned (verify fails closed)" >&2
else
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$AUDIT_PRIV" 2>/dev/null
chmod 600 "$AUDIT_PRIV"
fi
fi
if [[ -f "$AUDIT_PRIV" ]]; then
# Always re-export the public key so it matches the private key we sign with.
# Without this, a private key that PERSISTS on a CI runner drifts out of sync
# with a freshly checked-out audit-public.pem (e.g. one committed after a
# Vault-KMS signing), and verify-audit-chain.sh would reject a genuine head.
openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null || true
printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt"
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
fi
# Always re-export the public key so it matches the private key we sign with.
# Without this, a private key that PERSISTS on a CI runner drifts out of sync
# with a freshly checked-out audit-public.pem (e.g. one committed after a
# Vault-KMS signing), and verify-audit-chain.sh would reject a genuine head.
openssl rsa -in "$AUDIT_PRIV" -pubout -out "$AUDIT_PUB" 2>/dev/null || true
printf '%s' "$RECORD_HASH" > "$AUDIT_DIR/audit-head.txt"
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$AUDIT_DIR/audit-head.sig" "$AUDIT_DIR/audit-head.txt" 2>/dev/null || true
fi
if [[ "$DECISION" != "approved" ]]; then