""" provenance.py - Tamper-evident audit ledger for DocSentry. Every document analysis is logged as a record in a SQLite database. Each record stores: - timestamp - document filename + SHA-256 - verdict band + risk score - SHA-256 of the previous ledger entry (hash chain) - record_hash: SHA-256 of (timestamp + doc hash + verdict + prev_hash) The chain construction means that retroactively tampering with any past record breaks the hash chain - giving DocSentry an audit trail that meets RBI's tamper-evident record-retention requirements (Master Direction on KYC, 2016, Para 67). This is conceptually a baby blockchain: hash-linked records, immutable in practice, verifiable in O(N). Public API: log_analysis(doc_path, sha256, risk_band, risk_score, extra) fetch_ledger(limit=100) verify_chain() - returns (ok: bool, broken_at: index) chain_stats() - summary numbers for the UI ledger_dataframe() - pandas DataFrame view """ import os import json import sqlite3 import hashlib from datetime import datetime from pathlib import Path LEDGER_PATH = Path("provenance.db") def _conn(): LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True) con = sqlite3.connect(str(LEDGER_PATH)) con.execute("""CREATE TABLE IF NOT EXISTS ledger ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, doc_name TEXT NOT NULL, doc_sha256 TEXT NOT NULL, risk_band TEXT, risk_score REAL, extra_json TEXT, prev_hash TEXT, record_hash TEXT NOT NULL )""") con.commit() return con def _hash_record(ts, doc_sha256, risk_band, risk_score, prev_hash): payload = f"{ts}|{doc_sha256}|{risk_band}|{risk_score}|{prev_hash}".encode() return hashlib.sha256(payload).hexdigest() def log_analysis(doc_path, sha256, risk_band, risk_score, extra=None): """Append a new entry to the provenance ledger and return the new row.""" ts = datetime.utcnow().isoformat() + "Z" con = _conn() cur = con.cursor() cur.execute("SELECT record_hash FROM ledger ORDER BY id DESC LIMIT 1") last = cur.fetchone() prev_hash = last[0] if last else "GENESIS" rec_hash = _hash_record(ts, sha256, risk_band, risk_score, prev_hash) cur.execute("""INSERT INTO ledger (ts, doc_name, doc_sha256, risk_band, risk_score, extra_json, prev_hash, record_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (ts, Path(doc_path).name, sha256, risk_band, risk_score, json.dumps(extra or {}, default=str), prev_hash, rec_hash)) con.commit() new_id = cur.lastrowid con.close() return {"id": new_id, "ts": ts, "record_hash": rec_hash, "prev_hash": prev_hash} def fetch_ledger(limit=100): con = _conn() rows = con.execute("""SELECT id, ts, doc_name, doc_sha256, risk_band, risk_score, prev_hash, record_hash FROM ledger ORDER BY id DESC LIMIT ?""", (limit,)).fetchall() con.close() return [{ "id": r[0], "ts": r[1], "doc_name": r[2], "doc_sha256": r[3], "risk_band": r[4], "risk_score": r[5], "prev_hash": r[6], "record_hash": r[7], } for r in rows] def verify_chain(): """ Walk the ledger in order and verify each record_hash matches what should have been computed from (ts, doc_sha256, risk_band, risk_score, prev_hash). Returns (ok, broken_at). """ con = _conn() rows = con.execute("""SELECT id, ts, doc_sha256, risk_band, risk_score, prev_hash, record_hash FROM ledger ORDER BY id""").fetchall() con.close() prev = "GENESIS" for r in rows: rid, ts, doc_hash, band, score, stored_prev, stored_rec = r if stored_prev != prev: return False, rid expected = _hash_record(ts, doc_hash, band, score, prev) if expected != stored_rec: return False, rid prev = stored_rec return True, None def chain_stats(): con = _conn() n = con.execute("SELECT COUNT(*) FROM ledger").fetchone()[0] bands = con.execute("SELECT risk_band, COUNT(*) FROM ledger GROUP BY risk_band").fetchall() first = con.execute("SELECT ts FROM ledger ORDER BY id LIMIT 1").fetchone() last = con.execute("SELECT ts FROM ledger ORDER BY id DESC LIMIT 1").fetchone() con.close() ok, broken_at = verify_chain() return { "n_records": n, "first_ts": first[0] if first else None, "last_ts": last[0] if last else None, "by_band": dict(bands), "chain_intact": ok, "broken_at": broken_at, } def ledger_dataframe(limit=100): """Convenience: return ledger as a pandas DataFrame for Streamlit display.""" import pandas as pd rows = fetch_ledger(limit=limit) return pd.DataFrame(rows) def clear_ledger(): """Wipe the ledger (for demo resets only).""" con = _conn() con.execute("DELETE FROM ledger") con.commit() con.close() if __name__ == "__main__": # Smoke test clear_ledger() log_analysis("doc1.pdf", "a" * 64, "LOW", 0.05) log_analysis("doc2.pdf", "b" * 64, "MEDIUM", 0.42) log_analysis("doc3.pdf", "c" * 64, "HIGH", 0.74) log_analysis("doc4.pdf", "d" * 64, "CRITICAL", 0.91) print("Stats:", chain_stats()) print("\nLast 4 records:") for r in fetch_ledger(4): print(f" #{r['id']} {r['ts']} {r['doc_name']:8s} {r['risk_band']:9s} " f"hash={r['record_hash'][:12]}... prev={r['prev_hash'][:12]}...") ok, broken = verify_chain() print(f"\nChain verify: ok={ok}, broken_at={broken}") # Simulate tampering con = _conn() con.execute("UPDATE ledger SET risk_band='LOW' WHERE id=3") con.commit() con.close() ok, broken = verify_chain() print(f"After tampering: ok={ok}, broken_at={broken}") clear_ledger() print("Ledger cleared.")