feat: plan 16-01
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user