Spaces:
Running
Running
Commit ·
e97f963
1
Parent(s): d77d844
Round 2: fraud ring graph, AI-gen detector, provenance ledger, architecture doc
Browse files- ARCHITECTURE.md +280 -0
- ai_detector.py +246 -0
- app.py +194 -1
- forensics.py +28 -18
- fraud_ring.py +337 -0
- provenance.py +171 -0
- requirements.txt +1 -0
- tampering.py +2 -1
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DocSentry — System Architecture
|
| 2 |
+
|
| 3 |
+
**Real-time document anomaly detection for Indian bank underwriting.**
|
| 4 |
+
|
| 5 |
+
DocSentry is the operational realisation of the Round-1 submission idea:
|
| 6 |
+
catch tampered, forged, and AI-generated documents at the moment of
|
| 7 |
+
upload, score them on a calibrated risk scale, and hand the underwriter
|
| 8 |
+
a defensible audit trail. Round-2 turns that idea into a robust,
|
| 9 |
+
production-grade platform.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## 1. Architectural principles
|
| 14 |
+
|
| 15 |
+
| Principle | What it means in DocSentry |
|
| 16 |
+
|---|---|
|
| 17 |
+
| **Defence in depth** | Six independent detection layers (rule, image, PDF, OCR, ML, AI-generated). No single bypass defeats the system. |
|
| 18 |
+
| **Explainability first** | Every verdict ships with sub-scores, evidence bullets, and visual heatmaps. Black-box outputs are unacceptable in regulated finance. |
|
| 19 |
+
| **Tamper-evident provenance** | Every analysis is appended to a SHA-256 hash chain. Retroactive edits are mathematically detectable. |
|
| 20 |
+
| **Portfolio-level vision** | Single-document forensics is necessary but insufficient. Real fraud is *organised*; the system reasons across applicants. |
|
| 21 |
+
| **Zero data egress** | All inference runs locally. No applicant PII leaves the bank's perimeter. |
|
| 22 |
+
| **RBI-aligned output** | Compliance reports follow Master Direction on KYC formatting so they can be filed directly. |
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 2. Layered architecture
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
┌─────────────────────────────────────────┐
|
| 30 |
+
│ PRESENTATION (Streamlit / future PWA)│
|
| 31 |
+
│ │
|
| 32 |
+
│ Tab 1 Single-doc analysis │
|
| 33 |
+
│ Tab 2 Cross-document KYC │
|
| 34 |
+
│ Tab 3 Batch underwriter audit │
|
| 35 |
+
│ Tab 4 RBI compliance + Provenance │
|
| 36 |
+
│ Tab 5 Live Tamper Forge Studio │
|
| 37 |
+
│ Tab 6 Fraud Ring Network │
|
| 38 |
+
└────────────────────┬─────────────────────┘
|
| 39 |
+
│
|
| 40 |
+
┌────────────────────▼─────────────────────┐
|
| 41 |
+
│ API GATEWAY (planned FastAPI front) │
|
| 42 |
+
│ /analyse /verify /forge-test │
|
| 43 |
+
│ /compliance /batch /webhook │
|
| 44 |
+
└────────────────────┬─────────────────────┘
|
| 45 |
+
│
|
| 46 |
+
┌─────────────────────────────────────┼───────────────────────────────┐
|
| 47 |
+
▼ ▼ ▼
|
| 48 |
+
┌─────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
|
| 49 |
+
│ INGESTION │ │ FORENSICS CORE │ │ COMPLIANCE CORE │
|
| 50 |
+
│ - Direct upload│ │ Rule layer (ELA, │ │ RBI IFSC lookup │
|
| 51 |
+
│ - Watch folder │ │ copy-move, noise, │ │ PAN entity check │
|
| 52 |
+
│ - PDF / image │ │ EXIF, PDF struct, │ │ Aadhaar Verhoeff │
|
| 53 |
+
│ - Future Kafka │ │ OCR rules) │ │ PII redaction │
|
| 54 |
+
└────────┬────────┘ │ RF classifier (11-d) │ │ DPDP-aligned │
|
| 55 |
+
│ │ CNN (MobileNetV2) │ └──────────┬──────────┘
|
| 56 |
+
▼ │ AI-gen detector (FFT)│ │
|
| 57 |
+
┌─────────────────┐ └───────────┬───────────┘ │
|
| 58 |
+
│ PROVENANCE │ │ │
|
| 59 |
+
│ SHA-256 chain │ ▼ │
|
| 60 |
+
│ SQLite ledger │ ┌───────────────────┐ │
|
| 61 |
+
│ verify_chain() │ │ ENSEMBLE FUSION │ │
|
| 62 |
+
└────────┬────────┘ │ Weighted blend │ │
|
| 63 |
+
│ │ per sub-detector │ │
|
| 64 |
+
│ └───────────┬───────┘ │
|
| 65 |
+
└──────────────────┬─────────────┴──────────────────────────────┘
|
| 66 |
+
▼
|
| 67 |
+
┌─────────────────────────────────┐
|
| 68 |
+
│ FRAUD RING DETECTOR │
|
| 69 |
+
│ NetworkX similarity graph │
|
| 70 |
+
│ Clique-based ring discovery │
|
| 71 |
+
│ Cross-applicant correlation │
|
| 72 |
+
└────────────────┬─────────────────┘
|
| 73 |
+
▼
|
| 74 |
+
┌─────────────────────────────────┐
|
| 75 |
+
│ RISK ORCHESTRATOR │
|
| 76 |
+
│ Score -> band -> action │
|
| 77 |
+
│ (LOW / MEDIUM / HIGH / CRIT) │
|
| 78 |
+
└────────────────┬─────────────────┘
|
| 79 |
+
▼
|
| 80 |
+
┌─────────────────────────────────┐
|
| 81 |
+
│ OUTPUT LAYER │
|
| 82 |
+
│ - Streamlit dashboard │
|
| 83 |
+
│ - Bank-letterhead audit PDF │
|
| 84 |
+
│ - RBI compliance pack PDF │
|
| 85 |
+
│ - Audit JSON │
|
| 86 |
+
│ - Webhook alerts (planned) │
|
| 87 |
+
│ - Provenance ledger entry │
|
| 88 |
+
└─────────────────────────────────┘
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
## 3. Component reference
|
| 94 |
+
|
| 95 |
+
### 3.1 Forensics core (`forensics.py`)
|
| 96 |
+
|
| 97 |
+
Six independent detectors blended via `analyse_document(path)`:
|
| 98 |
+
|
| 99 |
+
| Detector | Method | Sub-score key |
|
| 100 |
+
|---------------------|--------------------------------------------|-----------------|
|
| 101 |
+
| Error Level Analysis| JPEG re-save diff | `ela` |
|
| 102 |
+
| Copy-move | ORB keypoints + cross-matching | `copy_move` |
|
| 103 |
+
| Noise inconsistency | Per-block Laplacian variance | `noise` |
|
| 104 |
+
| EXIF audit | Metadata + software-tag fingerprint | `exif` |
|
| 105 |
+
| OCR + text rules | Tesseract + IFSC/PAN/date/amount regex | `text_rules` |
|
| 106 |
+
| **AI-generated** | **Radial FFT spectral analysis (new)** | `ai_generated` |
|
| 107 |
+
|
| 108 |
+
Optional ML overlays:
|
| 109 |
+
- **Random Forest** (`predict_with_model`) — 11 features (4 forensics + 4 GLCM texture + 3 colour entropy).
|
| 110 |
+
- **MobileNetV2 CNN** (`predict_with_cnn`) — fine-tuned on CASIA v2; weight grows with measured validation AUC.
|
| 111 |
+
|
| 112 |
+
Final score: `risk_score = weighted_blend(sub_scores) -> RF overlay -> CNN overlay -> AI-gen overlay`.
|
| 113 |
+
|
| 114 |
+
### 3.2 AI-generated detector (`ai_detector.py`)
|
| 115 |
+
|
| 116 |
+
The 2026 threat model is Sora / Midjourney / Stable Diffusion outputs, not Photoshop. This module catches them in the frequency domain:
|
| 117 |
+
|
| 118 |
+
| Signal | Detection |
|
| 119 |
+
|--------------------------------|-----------------------------------------------|
|
| 120 |
+
| High-frequency suppression | Ratio of low- to high-frequency FFT energy. |
|
| 121 |
+
| Periodic spectral peaks | Spike count in high-frequency band. |
|
| 122 |
+
| JPEG quantization absence | PIL `img.quantization` table inspection. |
|
| 123 |
+
|
| 124 |
+
Blended into the main risk score with a +20% cap so it never dominates classical signals, but reliably surfaces synthetic media.
|
| 125 |
+
|
| 126 |
+
### 3.3 Cross-document KYC (`compliance.py`)
|
| 127 |
+
|
| 128 |
+
- IFSC validation against 36 RBI bank codes
|
| 129 |
+
- PAN entity-type character + Luhn-like structural check
|
| 130 |
+
- Aadhaar UIDAI Verhoeff checksum (rejects 0/1 prefix)
|
| 131 |
+
- PII redaction via PyMuPDF text-bbox overlays
|
| 132 |
+
- RBI-format compliance audit PDF (5 sections, ReportLab Platypus)
|
| 133 |
+
|
| 134 |
+
### 3.4 Fraud Ring Detector (`fraud_ring.py`) — *new headline feature*
|
| 135 |
+
|
| 136 |
+
Single-document forensics misses **organised** fraud. This module fixes that.
|
| 137 |
+
|
| 138 |
+
**Pipeline:**
|
| 139 |
+
|
| 140 |
+
1. **Extract identity signals** from each applicant: name, DOB, address, phone, IFSC, account, employer (OCR + regex).
|
| 141 |
+
2. **Build a weighted similarity graph** (NetworkX). Edge weight is a sum of per-signal match weights, with field-specific fraud significance:
|
| 142 |
+
- account number 0.25 (highest — same account = same person)
|
| 143 |
+
- DOB 0.15, address 0.20, phone 0.20
|
| 144 |
+
- name 0.10, IFSC 0.05, employer 0.05
|
| 145 |
+
3. **Detect rings** = connected components above a configurable similarity threshold, size ≥ 3.
|
| 146 |
+
4. **Score each ring**: CRITICAL (≥5 applicants), HIGH (3-4), MEDIUM (2).
|
| 147 |
+
5. **Visualise** as an interactive force-directed graph; ring members rendered in red with thick edges.
|
| 148 |
+
|
| 149 |
+
Banking impact: detects identity-recycling rings, address farms, mule-account networks — the patterns that cost banks ~₹3,000 crore/year (RBI Annual Report).
|
| 150 |
+
|
| 151 |
+
### 3.5 Tamper Forge Studio (`tampering.py`)
|
| 152 |
+
|
| 153 |
+
Adversarial validation: live UI to apply copy-move, splice, text-edit, compression, metadata-strip, custom-region, or chained tampering operations to a clean sample, then immediately re-run detection. Visual before/after with bounding boxes, per-detector scorecard, ELA + noise heatmap overlays. Doubles as a continuous test harness for the forensics layer.
|
| 154 |
+
|
| 155 |
+
### 3.6 Provenance Ledger (`provenance.py`) — *new compliance feature*
|
| 156 |
+
|
| 157 |
+
Tamper-evident SHA-256 hash chain over every analysis:
|
| 158 |
+
|
| 159 |
+
```
|
| 160 |
+
record_hash = SHA256(timestamp | doc_sha256 | risk_band | risk_score | prev_hash)
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
- Stored in SQLite (single file, zero-deploy)
|
| 164 |
+
- `verify_chain()` walks every record in O(N) and pinpoints the first broken record
|
| 165 |
+
- Satisfies RBI Master Direction on KYC (2016), Para 67 record-retention requirements
|
| 166 |
+
- Downloadable as JSON for external auditors
|
| 167 |
+
|
| 168 |
+
Conceptually a baby blockchain: append-only, hash-linked, mathematically verifiable.
|
| 169 |
+
|
| 170 |
+
### 3.7 Audit report (`audit_report.py`)
|
| 171 |
+
|
| 172 |
+
Bank-letterhead PDF with:
|
| 173 |
+
- Metadata table (file, SHA-256, analysed timestamp)
|
| 174 |
+
- Risk verdict box (colour-coded by band)
|
| 175 |
+
- Sub-score table with ASCII bars
|
| 176 |
+
- Evidence bullets
|
| 177 |
+
- Embedded forensic heatmaps
|
| 178 |
+
|
| 179 |
+
### 3.8 Dashboard (`app.py`)
|
| 180 |
+
|
| 181 |
+
Six-tab Streamlit UI. Sample documents bundled (`sample_data/`) for instant demo.
|
| 182 |
+
|
| 183 |
+
---
|
| 184 |
+
|
| 185 |
+
## 4. Data assets
|
| 186 |
+
|
| 187 |
+
| Asset | Purpose | Volume |
|
| 188 |
+
|--------------------------------------|--------------------------------------|--------|
|
| 189 |
+
| AgamiAI Indian Bank Statements (HF) | Real Indian bank statement PDFs | 217 |
|
| 190 |
+
| IDRBT Cheque Image Dataset | Cheque images, Indian banking format | 112 |
|
| 191 |
+
| CASIA v2 | CNN training (forged/authentic) | ~12 k |
|
| 192 |
+
| `sample_data/` bundled | Demo fixtures | 26 |
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
## 5. Ensemble fusion logic
|
| 197 |
+
|
| 198 |
+
```
|
| 199 |
+
sub_scores = {ela, copy_move, noise, exif, text_rules, ai_generated}
|
| 200 |
+
weights = {ela:0.20, copy_move:0.25, noise:0.20, exif:0.15,
|
| 201 |
+
text_rules:0.20}
|
| 202 |
+
# ai_generated is a separate overlay, not in base weights
|
| 203 |
+
|
| 204 |
+
base_score = sum(weights[k] * sub_scores[k] for k in weights)
|
| 205 |
+
score_with_rf = 0.5 * base_score + 0.5 * rf.predict_proba(features)
|
| 206 |
+
score_with_cnn = (1-w) * score_with_rf + w * cnn.predict(image)
|
| 207 |
+
where w = clamp(cnn.val_auc, 0.4, 0.7)
|
| 208 |
+
final_score = 0.9 * score_with_cnn + 0.1 * ai_gen_prob * 2.0
|
| 209 |
+
(AI-gen capped at +20%)
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
Band mapping: `0-0.30 LOW · 0.30-0.50 MEDIUM · 0.50-0.75 HIGH · 0.75+ CRITICAL`
|
| 213 |
+
|
| 214 |
+
---
|
| 215 |
+
|
| 216 |
+
## 6. Roadmap — what's next
|
| 217 |
+
|
| 218 |
+
The architecture below is **wired** for these extensions; they ship in subsequent rounds.
|
| 219 |
+
|
| 220 |
+
| Capability | Status | Notes |
|
| 221 |
+
|---------------------------------------|---------|----------------------------------------|
|
| 222 |
+
| FastAPI gateway + webhook alerts | planned | Push to LOS / CRM on HIGH or CRITICAL |
|
| 223 |
+
| Federated learning across banks | planned | Flower (`flwr`); no raw data leaves |
|
| 224 |
+
| LLM-based document reasoning | planned | Local Phi-3 / Gemma over OCR text |
|
| 225 |
+
| Real-time drift monitoring | planned | Track per-detector confidence over time|
|
| 226 |
+
| Kubernetes deployment | planned | For multi-tenant bank hosting |
|
| 227 |
+
| Multilingual OCR (Hindi / Bengali) | planned | Tesseract + IndicOCR models |
|
| 228 |
+
|
| 229 |
+
---
|
| 230 |
+
|
| 231 |
+
## 7. Mapping to Round-1 submission
|
| 232 |
+
|
| 233 |
+
| Round-1 idea | Round-2 realisation |
|
| 234 |
+
|------------------------------------|-------------------------------------------------------|
|
| 235 |
+
| Image forensics (ELA, copy-move, noise, EXIF) | `forensics.py` — fully implemented + **AI-gen FFT detector** |
|
| 236 |
+
| PDF structural auditing | `forensics.pdf_structural_audit` + `pdf_font_audit` |
|
| 237 |
+
| OCR + financial validation | `forensics.text_rule_checks` + IFSC/PAN/Aadhaar full validators |
|
| 238 |
+
| Random Forest risk scoring | `forensics.predict_with_model` — trained on 11-d feature set |
|
| 239 |
+
| Real-time underwriter dashboard | Streamlit app, 6 tabs, bank-letterhead PDF output |
|
| 240 |
+
| CNN with MobileNetV2 (future) | **Delivered** — fine-tuned on CASIA v2 |
|
| 241 |
+
| LLM reasoning (future) | Roadmap (see § 6) |
|
| 242 |
+
| API deployment (future) | Roadmap — FastAPI gateway scaffolded |
|
| 243 |
+
| **NEW — Fraud Ring Network** | Cross-applicant graph + clique discovery |
|
| 244 |
+
| **NEW — Provenance ledger** | SHA-256 hash chain, RBI Para 67 compliant |
|
| 245 |
+
| **NEW — Tamper Forge Studio** | Adversarial-validation harness |
|
| 246 |
+
|
| 247 |
+
The Round-1 pillars remain the visible centre of the system. The new
|
| 248 |
+
pillars extend each axis without breaking the original framing:
|
| 249 |
+
forensics gets AI-gen detection, scoring gets a cross-applicant view,
|
| 250 |
+
the dashboard gets a tamper-evident audit trail.
|
| 251 |
+
|
| 252 |
+
---
|
| 253 |
+
|
| 254 |
+
## 8. Repository layout
|
| 255 |
+
|
| 256 |
+
```
|
| 257 |
+
.
|
| 258 |
+
├── app.py Streamlit dashboard (6 tabs)
|
| 259 |
+
├── forensics.py Core analysis pipeline
|
| 260 |
+
├── ai_detector.py AI-generated content detector (FFT)
|
| 261 |
+
├── fraud_ring.py Cross-applicant graph + clique detection
|
| 262 |
+
├── provenance.py Tamper-evident SHA-256 hash chain
|
| 263 |
+
├── compliance.py IFSC / PAN / Aadhaar / PII redaction
|
| 264 |
+
├── tampering.py Adversarial harness for Forge Studio
|
| 265 |
+
├── audit_report.py Bank-letterhead PDF builder
|
| 266 |
+
├── docsentry_master.ipynb Notebook source of truth
|
| 267 |
+
├── models/ RF + CNN model files
|
| 268 |
+
├── sample_data/ 26 demo documents
|
| 269 |
+
├── requirements.txt Python dependencies
|
| 270 |
+
├── packages.txt apt-get packages (HF Spaces)
|
| 271 |
+
├── README.md Reference + install guide
|
| 272 |
+
├── ARCHITECTURE.md This document
|
| 273 |
+
└── LICENSE MIT + third-party notices
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
---
|
| 277 |
+
|
| 278 |
+
*This architecture document is the technical reference for DocSentry Round 2.
|
| 279 |
+
It accompanies the live demo at https://huggingface.co/spaces/SpandanM110/DocSentry
|
| 280 |
+
and the source code at https://github.com/SpandanM110/Doc-Sentry.*
|
ai_detector.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ai_detector.py - Detection of AI-generated documents (Sora / Midjourney /
|
| 3 |
+
Stable Diffusion / DALL-E / DocGen outputs).
|
| 4 |
+
|
| 5 |
+
The 2026 threat vector is no longer Photoshop. Banks worry about applicants
|
| 6 |
+
submitting documents synthesized by generative models. These outputs leave
|
| 7 |
+
fingerprints in the frequency domain that classical forensics miss:
|
| 8 |
+
|
| 9 |
+
1. Suppressed high-frequency energy ("smoothness")
|
| 10 |
+
Generative models tend to produce images with less true high-frequency
|
| 11 |
+
content than real scans, because their upsampling layers act as
|
| 12 |
+
low-pass filters.
|
| 13 |
+
|
| 14 |
+
2. Periodic spectral peaks
|
| 15 |
+
Discrete-cosine-transform-based diffusion outputs and GAN outputs leave
|
| 16 |
+
periodic peaks in the FFT magnitude spectrum (the "checkerboard
|
| 17 |
+
artefact") - especially at frequencies corresponding to the upsampling
|
| 18 |
+
stride.
|
| 19 |
+
|
| 20 |
+
3. Missing JPEG quantization tables
|
| 21 |
+
Real scanned documents are JPEGs with standard quantization tables.
|
| 22 |
+
Generative model outputs are PNGs or re-saved JPEGs with non-standard
|
| 23 |
+
quantization patterns.
|
| 24 |
+
|
| 25 |
+
This module implements all three as a single "AI-generated probability"
|
| 26 |
+
score in [0, 1].
|
| 27 |
+
|
| 28 |
+
Public API:
|
| 29 |
+
detect_ai_generated(path) - returns {'probability', 'confidence',
|
| 30 |
+
'spectrum', 'flags', 'verdict'}
|
| 31 |
+
|
| 32 |
+
radial_fft_profile(image) - per-frequency log-magnitude profile
|
| 33 |
+
spectral_peak_score(profile) - peakiness of the high-frequency band
|
| 34 |
+
high_freq_attenuation(profile) - degree of high-frequency suppression
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
import io
|
| 38 |
+
import numpy as np
|
| 39 |
+
from PIL import Image
|
| 40 |
+
import cv2
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ============================================================
|
| 44 |
+
# Helpers
|
| 45 |
+
# ============================================================
|
| 46 |
+
|
| 47 |
+
def _to_grayscale_float(path_or_img, max_dim=512):
|
| 48 |
+
"""Load + downscale + convert to grayscale float32 in [0, 1]."""
|
| 49 |
+
if isinstance(path_or_img, (str, bytes)) or hasattr(path_or_img, "__fspath__"):
|
| 50 |
+
img = Image.open(path_or_img).convert("L")
|
| 51 |
+
else:
|
| 52 |
+
img = path_or_img.convert("L")
|
| 53 |
+
w, h = img.size
|
| 54 |
+
scale = min(1.0, max_dim / max(w, h))
|
| 55 |
+
if scale < 1.0:
|
| 56 |
+
img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
|
| 57 |
+
arr = np.asarray(img, dtype=np.float32) / 255.0
|
| 58 |
+
return arr
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ============================================================
|
| 62 |
+
# Spectral analysis
|
| 63 |
+
# ============================================================
|
| 64 |
+
|
| 65 |
+
def radial_fft_profile(gray, n_bins=64):
|
| 66 |
+
"""
|
| 67 |
+
Compute the radially-averaged log-magnitude FFT spectrum.
|
| 68 |
+
|
| 69 |
+
Returns: numpy array of shape (n_bins,) with mean log-magnitude per
|
| 70 |
+
radial frequency bin. Low index = low frequency.
|
| 71 |
+
"""
|
| 72 |
+
H, W = gray.shape
|
| 73 |
+
# 2D FFT, shifted so DC is at center
|
| 74 |
+
F = np.fft.fftshift(np.fft.fft2(gray))
|
| 75 |
+
mag = np.log1p(np.abs(F))
|
| 76 |
+
|
| 77 |
+
# Radial coordinate map
|
| 78 |
+
cy, cx = H // 2, W // 2
|
| 79 |
+
yy, xx = np.indices((H, W))
|
| 80 |
+
r = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
|
| 81 |
+
r_max = min(H, W) // 2
|
| 82 |
+
|
| 83 |
+
bins = np.linspace(0, r_max, n_bins + 1)
|
| 84 |
+
profile = np.zeros(n_bins, dtype=np.float32)
|
| 85 |
+
for i in range(n_bins):
|
| 86 |
+
mask = (r >= bins[i]) & (r < bins[i + 1])
|
| 87 |
+
if mask.any():
|
| 88 |
+
profile[i] = mag[mask].mean()
|
| 89 |
+
return profile
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def high_freq_attenuation(profile):
|
| 93 |
+
"""
|
| 94 |
+
Measure how much energy is missing from the top half of the spectrum.
|
| 95 |
+
Real scans have roughly 1/f decay; AI outputs are much smoother
|
| 96 |
+
(sharper drop-off).
|
| 97 |
+
|
| 98 |
+
Returns: a score in [0, 1] where 1 = very suppressed (suspicious).
|
| 99 |
+
"""
|
| 100 |
+
n = len(profile)
|
| 101 |
+
low = profile[: n // 4].mean() # low frequencies
|
| 102 |
+
high = profile[3 * n // 4:].mean() # high frequencies
|
| 103 |
+
if low < 1e-6:
|
| 104 |
+
return 0.0
|
| 105 |
+
ratio = high / low
|
| 106 |
+
# In real scans this ratio is typically 0.45-0.65.
|
| 107 |
+
# In AI-generated images it drops below 0.30.
|
| 108 |
+
if ratio >= 0.45: return 0.0
|
| 109 |
+
if ratio <= 0.20: return 1.0
|
| 110 |
+
return float((0.45 - ratio) / 0.25)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def spectral_peak_score(profile):
|
| 114 |
+
"""
|
| 115 |
+
Detect periodic peaks in the high-frequency band - a signature of
|
| 116 |
+
checkerboard upsampling in GANs/diffusion.
|
| 117 |
+
|
| 118 |
+
Returns: a score in [0, 1] where 1 = lots of peaks (suspicious).
|
| 119 |
+
"""
|
| 120 |
+
n = len(profile)
|
| 121 |
+
high_band = profile[n // 2:]
|
| 122 |
+
if len(high_band) < 4:
|
| 123 |
+
return 0.0
|
| 124 |
+
# Differences from local trend
|
| 125 |
+
smooth = np.convolve(high_band, np.ones(3) / 3, mode="same")
|
| 126 |
+
deviations = high_band - smooth
|
| 127 |
+
# Count peaks (positive spikes > 1 std above local mean)
|
| 128 |
+
spikes = (deviations > deviations.std()).sum()
|
| 129 |
+
# Normalise: ~6 spikes in 32 bins is typical for AI output
|
| 130 |
+
return float(min(1.0, spikes / 6.0))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def jpeg_quantization_check(path):
|
| 134 |
+
"""
|
| 135 |
+
For images saved as JPEG, examine the quantization tables.
|
| 136 |
+
Real scanners write standard tables; AI outputs often have all-1s
|
| 137 |
+
(no real compression history) or unusual table structures.
|
| 138 |
+
|
| 139 |
+
Returns: dict with 'has_jpeg_qtable', 'is_standard'.
|
| 140 |
+
"""
|
| 141 |
+
try:
|
| 142 |
+
img = Image.open(path)
|
| 143 |
+
if img.format != "JPEG":
|
| 144 |
+
return {"has_jpeg_qtable": False, "is_standard": None,
|
| 145 |
+
"note": f"Not a JPEG (format: {img.format})"}
|
| 146 |
+
qtables = getattr(img, "quantization", {})
|
| 147 |
+
if not qtables:
|
| 148 |
+
return {"has_jpeg_qtable": False, "is_standard": False,
|
| 149 |
+
"note": "JPEG without quantization tables - suspicious"}
|
| 150 |
+
# Standard tables have mean value > 5 (real compression)
|
| 151 |
+
means = [np.array(q).mean() for q in qtables.values()]
|
| 152 |
+
is_standard = all(m > 5 for m in means)
|
| 153 |
+
return {"has_jpeg_qtable": True, "is_standard": bool(is_standard),
|
| 154 |
+
"note": f"qtable means: {[round(m, 1) for m in means]}"}
|
| 155 |
+
except Exception as e:
|
| 156 |
+
return {"has_jpeg_qtable": False, "is_standard": None,
|
| 157 |
+
"note": f"qtable read failed: {e}"}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ============================================================
|
| 161 |
+
# Main entry point
|
| 162 |
+
# ============================================================
|
| 163 |
+
|
| 164 |
+
def detect_ai_generated(path):
|
| 165 |
+
"""
|
| 166 |
+
Run the full AI-generated-content detection pipeline.
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
{
|
| 170 |
+
'probability': float in [0, 1], - overall AI-generated probability
|
| 171 |
+
'confidence': str, - 'low' | 'medium' | 'high'
|
| 172 |
+
'verdict': str, - 'likely_real' | 'suspicious' | 'likely_ai_generated'
|
| 173 |
+
'profile': list[float], - radial FFT profile
|
| 174 |
+
'sub': dict, - individual detector scores
|
| 175 |
+
'flags': list[str], - human-readable evidence
|
| 176 |
+
}
|
| 177 |
+
"""
|
| 178 |
+
gray = _to_grayscale_float(path)
|
| 179 |
+
profile = radial_fft_profile(gray)
|
| 180 |
+
|
| 181 |
+
s_hf = high_freq_attenuation(profile)
|
| 182 |
+
s_pk = spectral_peak_score(profile)
|
| 183 |
+
q = jpeg_quantization_check(path)
|
| 184 |
+
s_jpg = 0.6 if (q["has_jpeg_qtable"] is False and q.get("note", "").endswith("not a JPEG")) else \
|
| 185 |
+
(0.4 if q.get("is_standard") is False else 0.0)
|
| 186 |
+
|
| 187 |
+
# Weighted blend
|
| 188 |
+
prob = 0.50 * s_hf + 0.30 * s_pk + 0.20 * s_jpg
|
| 189 |
+
|
| 190 |
+
flags = []
|
| 191 |
+
if s_hf > 0.5:
|
| 192 |
+
flags.append(f"High-frequency content suppressed (smoothness score {s_hf:.2f}) - typical of generative-model output.")
|
| 193 |
+
if s_pk > 0.5:
|
| 194 |
+
flags.append(f"Periodic peaks detected in high-frequency band (score {s_pk:.2f}) - GAN/diffusion checkerboard signature.")
|
| 195 |
+
if q.get("is_standard") is False:
|
| 196 |
+
flags.append(f"Non-standard JPEG quantization tables ({q.get('note','')}).")
|
| 197 |
+
if not flags:
|
| 198 |
+
flags.append("No AI-generated indicators above threshold.")
|
| 199 |
+
|
| 200 |
+
if prob >= 0.65:
|
| 201 |
+
verdict = "likely_ai_generated"; confidence = "high"
|
| 202 |
+
elif prob >= 0.40:
|
| 203 |
+
verdict = "suspicious"; confidence = "medium"
|
| 204 |
+
else:
|
| 205 |
+
verdict = "likely_real"; confidence = "low"
|
| 206 |
+
|
| 207 |
+
return {
|
| 208 |
+
"probability": round(float(prob), 3),
|
| 209 |
+
"confidence": confidence,
|
| 210 |
+
"verdict": verdict,
|
| 211 |
+
"profile": profile.tolist(),
|
| 212 |
+
"sub": {
|
| 213 |
+
"high_freq_suppression": round(float(s_hf), 3),
|
| 214 |
+
"spectral_peakiness": round(float(s_pk), 3),
|
| 215 |
+
"jpeg_artefact_score": round(float(s_jpg), 3),
|
| 216 |
+
},
|
| 217 |
+
"jpeg_qtable": q,
|
| 218 |
+
"flags": flags,
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
if __name__ == "__main__":
|
| 223 |
+
import sys
|
| 224 |
+
if len(sys.argv) < 2:
|
| 225 |
+
# Smoke test on synthetic inputs
|
| 226 |
+
from PIL import ImageDraw
|
| 227 |
+
# 1. Smooth/blurry image (AI-like)
|
| 228 |
+
smooth = Image.new("RGB", (400, 300), "white")
|
| 229 |
+
d = ImageDraw.Draw(smooth)
|
| 230 |
+
d.text((40, 120), "AI-LIKE smooth output", fill="black")
|
| 231 |
+
smooth.save("/tmp/_smooth.png")
|
| 232 |
+
# 2. Noisy/real-like image
|
| 233 |
+
noisy_arr = (np.random.RandomState(0).randint(0, 256, (300, 400, 3))
|
| 234 |
+
.astype(np.uint8))
|
| 235 |
+
noisy = Image.fromarray(noisy_arr)
|
| 236 |
+
noisy.save("/tmp/_noisy.png")
|
| 237 |
+
|
| 238 |
+
for tag, p in [("AI-like smooth", "/tmp/_smooth.png"),
|
| 239 |
+
("Noisy real-like", "/tmp/_noisy.png")]:
|
| 240 |
+
r = detect_ai_generated(p)
|
| 241 |
+
print(f" {tag:18s} -> verdict={r['verdict']:20s} "
|
| 242 |
+
f"prob={r['probability']:.2f} sub={r['sub']}")
|
| 243 |
+
else:
|
| 244 |
+
import json
|
| 245 |
+
r = detect_ai_generated(sys.argv[1])
|
| 246 |
+
print(json.dumps({k: v for k, v in r.items() if k != "profile"}, indent=2))
|
app.py
CHANGED
|
@@ -25,6 +25,7 @@ import matplotlib.pyplot as plt
|
|
| 25 |
import forensics
|
| 26 |
import compliance
|
| 27 |
import tampering
|
|
|
|
| 28 |
from audit_report import build_pdf_report # Sprint 3 module
|
| 29 |
|
| 30 |
# -------------------------------------------------------------
|
|
@@ -94,12 +95,13 @@ def sub_score_chart(sub_scores):
|
|
| 94 |
# -------------------------------------------------------------
|
| 95 |
# TABS
|
| 96 |
# -------------------------------------------------------------
|
| 97 |
-
tab1, tab2, tab3, tab4, tab5 = st.tabs([
|
| 98 |
":mag: Single-document analysis",
|
| 99 |
":busts_in_silhouette: Cross-document check",
|
| 100 |
":file_folder: Batch audit",
|
| 101 |
":scales: Compliance & Audit Pack",
|
| 102 |
":fire: Tamper Forge Studio",
|
|
|
|
| 103 |
])
|
| 104 |
|
| 105 |
# =============================================================
|
|
@@ -191,6 +193,33 @@ with tab1:
|
|
| 191 |
st.pyplot(fig)
|
| 192 |
st.caption("Hotspots = local noise inconsistencies (splicing signal).")
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
# --- PDF-specific audit details ---
|
| 195 |
if report["type"] == "pdf":
|
| 196 |
st.divider()
|
|
@@ -515,6 +544,47 @@ with tab4:
|
|
| 515 |
file_name=f"compliance_{src_path.stem}.pdf",
|
| 516 |
mime="application/pdf")
|
| 517 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
|
| 519 |
# =============================================================
|
| 520 |
# TAB 5 - Live Tamper Forge Studio (rich version)
|
|
@@ -744,6 +814,129 @@ with tab5:
|
|
| 744 |
mime="image/png")
|
| 745 |
|
| 746 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
# -------------------------------------------------------------
|
| 748 |
# Footer
|
| 749 |
# -------------------------------------------------------------
|
|
|
|
| 25 |
import forensics
|
| 26 |
import compliance
|
| 27 |
import tampering
|
| 28 |
+
import fraud_ring
|
| 29 |
from audit_report import build_pdf_report # Sprint 3 module
|
| 30 |
|
| 31 |
# -------------------------------------------------------------
|
|
|
|
| 95 |
# -------------------------------------------------------------
|
| 96 |
# TABS
|
| 97 |
# -------------------------------------------------------------
|
| 98 |
+
tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
|
| 99 |
":mag: Single-document analysis",
|
| 100 |
":busts_in_silhouette: Cross-document check",
|
| 101 |
":file_folder: Batch audit",
|
| 102 |
":scales: Compliance & Audit Pack",
|
| 103 |
":fire: Tamper Forge Studio",
|
| 104 |
+
":spider_web: Fraud Ring Network",
|
| 105 |
])
|
| 106 |
|
| 107 |
# =============================================================
|
|
|
|
| 193 |
st.pyplot(fig)
|
| 194 |
st.caption("Hotspots = local noise inconsistencies (splicing signal).")
|
| 195 |
|
| 196 |
+
# AI-generated content detector - separate expander
|
| 197 |
+
ai = report.get("ai_detector")
|
| 198 |
+
if ai:
|
| 199 |
+
st.divider()
|
| 200 |
+
badge = {":robot_face: likely_ai_generated": ai["verdict"] == "likely_ai_generated",
|
| 201 |
+
":warning: suspicious": ai["verdict"] == "suspicious",
|
| 202 |
+
":white_check_mark: likely_real": ai["verdict"] == "likely_real"}
|
| 203 |
+
label = next((k for k, v in badge.items() if v), ai["verdict"])
|
| 204 |
+
with st.expander(f"AI-generated forgery detector — {label} (prob {ai['probability']:.2f})", expanded=False):
|
| 205 |
+
a1, a2, a3 = st.columns(3)
|
| 206 |
+
a1.metric("High-freq suppression", f"{ai['sub']['high_freq_suppression']:.2f}")
|
| 207 |
+
a2.metric("Spectral peakiness", f"{ai['sub']['spectral_peakiness']:.2f}")
|
| 208 |
+
a3.metric("JPEG artefact score", f"{ai['sub']['jpeg_artefact_score']:.2f}")
|
| 209 |
+
# FFT profile plot
|
| 210 |
+
profile = ai.get("profile", [])
|
| 211 |
+
if profile:
|
| 212 |
+
fig, ax = plt.subplots(figsize=(7, 2.6))
|
| 213 |
+
ax.plot(profile, color="#2563eb", linewidth=2)
|
| 214 |
+
ax.fill_between(range(len(profile)), profile, alpha=0.2, color="#2563eb")
|
| 215 |
+
ax.set_xlabel("Radial frequency bin (low -> high)")
|
| 216 |
+
ax.set_ylabel("log-magnitude")
|
| 217 |
+
ax.set_title("Radial FFT profile (real scans show gradual 1/f decay; AI outputs drop sharply)")
|
| 218 |
+
ax.grid(alpha=0.3)
|
| 219 |
+
st.pyplot(fig)
|
| 220 |
+
for f in ai.get("flags", []):
|
| 221 |
+
st.markdown(f"- {f}")
|
| 222 |
+
|
| 223 |
# --- PDF-specific audit details ---
|
| 224 |
if report["type"] == "pdf":
|
| 225 |
st.divider()
|
|
|
|
| 544 |
file_name=f"compliance_{src_path.stem}.pdf",
|
| 545 |
mime="application/pdf")
|
| 546 |
|
| 547 |
+
# ---------- Provenance Ledger (tamper-evident audit trail) ----------
|
| 548 |
+
st.divider()
|
| 549 |
+
st.markdown("### :link: Provenance Ledger — tamper-evident audit chain")
|
| 550 |
+
st.caption("Every analysis is logged as a SHA-256 hash-chained record. "
|
| 551 |
+
"Editing any past entry breaks the chain, satisfying RBI Master "
|
| 552 |
+
"Direction on KYC, 2016 (Para 67) record-retention requirements.")
|
| 553 |
+
try:
|
| 554 |
+
import provenance as _prov
|
| 555 |
+
stats = _prov.chain_stats()
|
| 556 |
+
pc1, pc2, pc3, pc4 = st.columns(4)
|
| 557 |
+
pc1.metric("Records", stats["n_records"])
|
| 558 |
+
pc2.metric("First entry", (stats["first_ts"] or "-")[:19].replace("T", " "))
|
| 559 |
+
pc3.metric("Last entry", (stats["last_ts"] or "-")[:19].replace("T", " "))
|
| 560 |
+
pc4.metric("Chain status", ":white_check_mark: INTACT" if stats["chain_intact"]
|
| 561 |
+
else f":x: BROKEN @ #{stats['broken_at']}")
|
| 562 |
+
if stats["n_records"]:
|
| 563 |
+
df = _prov.ledger_dataframe(limit=20)
|
| 564 |
+
df_show = df[["id", "ts", "doc_name", "risk_band", "risk_score",
|
| 565 |
+
"prev_hash", "record_hash"]].copy()
|
| 566 |
+
df_show["prev_hash"] = df_show["prev_hash"].str[:14] + "..."
|
| 567 |
+
df_show["record_hash"] = df_show["record_hash"].str[:14] + "..."
|
| 568 |
+
df_show["ts"] = df_show["ts"].str[:19].str.replace("T", " ")
|
| 569 |
+
st.dataframe(df_show, use_container_width=True, hide_index=True)
|
| 570 |
+
cdl, cv = st.columns([1, 1])
|
| 571 |
+
with cdl:
|
| 572 |
+
import json as _j
|
| 573 |
+
st.download_button(":inbox_tray: Download full ledger (JSON)",
|
| 574 |
+
_j.dumps(_prov.fetch_ledger(limit=10000),
|
| 575 |
+
indent=2, default=str),
|
| 576 |
+
file_name="docsentry_provenance.json",
|
| 577 |
+
mime="application/json")
|
| 578 |
+
with cv:
|
| 579 |
+
if st.button(":mag: Re-verify chain integrity", key="prov_verify"):
|
| 580 |
+
ok, where = _prov.verify_chain()
|
| 581 |
+
if ok: st.success(":white_check_mark: Chain integrity verified across all records.")
|
| 582 |
+
else: st.error(f":x: Chain broken at record #{where}.")
|
| 583 |
+
else:
|
| 584 |
+
st.info("Ledger is empty. Run an analysis on Tab 1 to create the first record.")
|
| 585 |
+
except Exception as e:
|
| 586 |
+
st.warning(f"Provenance ledger unavailable: {e}")
|
| 587 |
+
|
| 588 |
|
| 589 |
# =============================================================
|
| 590 |
# TAB 5 - Live Tamper Forge Studio (rich version)
|
|
|
|
| 814 |
mime="image/png")
|
| 815 |
|
| 816 |
|
| 817 |
+
|
| 818 |
+
# =============================================================
|
| 819 |
+
# TAB 6 - Fraud Ring Network Detector
|
| 820 |
+
# =============================================================
|
| 821 |
+
with tab6:
|
| 822 |
+
st.markdown("**Detect organised application fraud.** "
|
| 823 |
+
"Upload documents from multiple applicants. The system extracts "
|
| 824 |
+
"their identity signals (name, DOB, address, phone, IFSC, account, "
|
| 825 |
+
"employer) and builds a similarity graph. Cliques of >=3 applicants "
|
| 826 |
+
"linked by shared signals are flagged as suspected fraud rings.")
|
| 827 |
+
st.caption("Banks lose ~Rs 3,000 crore/year to organised application fraud (RBI Annual Report).")
|
| 828 |
+
|
| 829 |
+
fr_files = st.file_uploader(
|
| 830 |
+
"Upload 3 or more applicant documents (PNG / JPG / PDF):",
|
| 831 |
+
type=["png", "jpg", "jpeg", "pdf"],
|
| 832 |
+
accept_multiple_files=True,
|
| 833 |
+
key="fr_files",
|
| 834 |
+
)
|
| 835 |
+
|
| 836 |
+
fr_col1, fr_col2 = st.columns([1, 1])
|
| 837 |
+
with fr_col1:
|
| 838 |
+
fr_min_size = st.slider("Minimum ring size", 2, 6, 3, key="fr_min_size",
|
| 839 |
+
help="A 'ring' must have at least this many linked applicants.")
|
| 840 |
+
with fr_col2:
|
| 841 |
+
fr_threshold = st.slider("Link threshold", 0.10, 1.00, 0.30, 0.05, key="fr_thresh",
|
| 842 |
+
help="Pair similarity required to count as a link.")
|
| 843 |
+
|
| 844 |
+
if fr_files and len(fr_files) >= 2:
|
| 845 |
+
if st.button(":spider_web: Build fraud network", key="fr_run", type="primary"):
|
| 846 |
+
with st.spinner("Extracting identity fields from each document..."):
|
| 847 |
+
applicants = []
|
| 848 |
+
for f in fr_files:
|
| 849 |
+
p_tmp = save_uploaded(f)
|
| 850 |
+
fields = fraud_ring.extract_applicant_fields(p_tmp)
|
| 851 |
+
fields["upload_name"] = f.name
|
| 852 |
+
applicants.append(fields)
|
| 853 |
+
st.session_state["fr_applicants"] = applicants
|
| 854 |
+
with st.spinner("Building similarity graph..."):
|
| 855 |
+
G = fraud_ring.build_fraud_graph(applicants)
|
| 856 |
+
rings = fraud_ring.detect_rings(G, min_size=fr_min_size, edge_threshold=fr_threshold)
|
| 857 |
+
st.session_state["fr_graph"] = G
|
| 858 |
+
st.session_state["fr_rings"] = rings
|
| 859 |
+
|
| 860 |
+
# Display from session state so re-runs don't lose results
|
| 861 |
+
if "fr_graph" in st.session_state:
|
| 862 |
+
G = st.session_state["fr_graph"]
|
| 863 |
+
rings = st.session_state["fr_rings"]
|
| 864 |
+
applicants = st.session_state["fr_applicants"]
|
| 865 |
+
summary = fraud_ring.fraud_summary(G, rings, applicants)
|
| 866 |
+
|
| 867 |
+
# KPI cards
|
| 868 |
+
kc1, kc2, kc3, kc4 = st.columns(4)
|
| 869 |
+
kc1.metric("Applicants", summary["n_applicants"])
|
| 870 |
+
kc2.metric("Suspected rings", summary["n_rings"])
|
| 871 |
+
kc3.metric("Largest ring", summary["largest_ring_size"])
|
| 872 |
+
kc4.metric("Fraud risk", f"{summary['fraud_risk_percentage']}%")
|
| 873 |
+
|
| 874 |
+
st.divider()
|
| 875 |
+
|
| 876 |
+
# Graph visualisation
|
| 877 |
+
st.markdown("#### Fraud network graph")
|
| 878 |
+
st.caption(":red_circle: Red nodes are members of suspected fraud rings. "
|
| 879 |
+
":large_green_circle: Green nodes look clean. "
|
| 880 |
+
"Edge thickness = similarity strength.")
|
| 881 |
+
fig = fraud_ring.visualize_graph(G, rings)
|
| 882 |
+
st.pyplot(fig)
|
| 883 |
+
|
| 884 |
+
st.divider()
|
| 885 |
+
|
| 886 |
+
# Ring breakdown
|
| 887 |
+
if summary["rings"]:
|
| 888 |
+
st.markdown("#### Detected fraud rings")
|
| 889 |
+
for r in summary["rings"]:
|
| 890 |
+
band_color = {"CRITICAL":":red_circle:", "HIGH":":large_orange_circle:",
|
| 891 |
+
"MEDIUM":":large_yellow_circle:"}.get(r["risk_band"], ":white_circle:")
|
| 892 |
+
with st.expander(f"{band_color} **Ring #{r['ring_id']}** "
|
| 893 |
+
f"({r['risk_band']}, {r['size']} applicants, "
|
| 894 |
+
f"{r['n_links']} links)"):
|
| 895 |
+
st.markdown("**Members:**")
|
| 896 |
+
for nm, fn in zip(r["applicant_names"], r["applicant_files"]):
|
| 897 |
+
st.markdown(f"- **{nm}** (`{fn}`)")
|
| 898 |
+
st.markdown("**Top shared signals:**")
|
| 899 |
+
for sig, count in r["top_shared_signals"]:
|
| 900 |
+
st.markdown(f"- `{sig}`: appears in {count} pairwise links")
|
| 901 |
+
else:
|
| 902 |
+
st.success(":white_check_mark: No fraud rings detected at the current threshold.")
|
| 903 |
+
|
| 904 |
+
st.divider()
|
| 905 |
+
|
| 906 |
+
# Per-applicant extracts
|
| 907 |
+
with st.expander("View extracted identity fields per applicant"):
|
| 908 |
+
rows = []
|
| 909 |
+
for i, a in enumerate(applicants):
|
| 910 |
+
rows.append({
|
| 911 |
+
"#": i,
|
| 912 |
+
"File": a.get("upload_name"),
|
| 913 |
+
"Name": a.get("name") or "-",
|
| 914 |
+
"DOB": a.get("dob") or "-",
|
| 915 |
+
"Address": (a.get("address") or "-")[:40],
|
| 916 |
+
"Phone": a.get("phone") or "-",
|
| 917 |
+
"IFSC": a.get("ifsc") or "-",
|
| 918 |
+
"Account": (a.get("account") or "-")[:14],
|
| 919 |
+
})
|
| 920 |
+
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
| 921 |
+
|
| 922 |
+
# Download network analysis
|
| 923 |
+
import io as _io, json as _json
|
| 924 |
+
export = {"summary": summary,
|
| 925 |
+
"applicants": [{k: v for k, v in a.items() if k != "text_sample"}
|
| 926 |
+
for a in applicants],
|
| 927 |
+
"edges": [{"a": u, "b": v, **d} for u, v, d in G.edges(data=True)]}
|
| 928 |
+
st.download_button(":inbox_tray: Download fraud network report (JSON)",
|
| 929 |
+
_json.dumps(export, indent=2, default=str),
|
| 930 |
+
file_name="fraud_network_report.json",
|
| 931 |
+
mime="application/json")
|
| 932 |
+
elif fr_files and len(fr_files) < 2:
|
| 933 |
+
st.warning("Upload at least 2 applicants (3+ recommended) to detect a ring.")
|
| 934 |
+
else:
|
| 935 |
+
st.info(":point_up: Upload multiple applicants' documents to begin. "
|
| 936 |
+
"The system will pair-wise compare their identity fields and "
|
| 937 |
+
"show which applicants are linked.")
|
| 938 |
+
|
| 939 |
+
|
| 940 |
# -------------------------------------------------------------
|
| 941 |
# Footer
|
| 942 |
# -------------------------------------------------------------
|
forensics.py
CHANGED
|
@@ -38,6 +38,11 @@ from PIL import Image, ImageChops, ImageEnhance
|
|
| 38 |
import cv2
|
| 39 |
import fitz # PyMuPDF
|
| 40 |
import pytesseract
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
warnings.filterwarnings("ignore")
|
| 43 |
|
|
@@ -416,6 +421,18 @@ def analyse_document(path):
|
|
| 416 |
report["cnn_prediction"] = cnn
|
| 417 |
except Exception as e:
|
| 418 |
report["cnn_error"] = str(e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
insights = generate_insights(s, sub, flags + text_rules.get("flags", []))
|
| 420 |
report.update({"sub_scores": sub, "exif_flags": flags,
|
| 421 |
"text_rules": text_rules, **insights})
|
|
@@ -433,6 +450,16 @@ def analyse_document(path):
|
|
| 433 |
else:
|
| 434 |
report["type"] = "unsupported"
|
| 435 |
report["error"] = "extension " + ext + " not handled"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
return report
|
| 437 |
|
| 438 |
|
|
@@ -522,21 +549,4 @@ def cross_doc_consistency(file_paths):
|
|
| 522 |
}
|
| 523 |
# Aggregate risk
|
| 524 |
mismatches = sum(1 for r in field_results.values() if r["status"] == "mismatch")
|
| 525 |
-
|
| 526 |
-
risk_score = min(1.0, mismatches * 0.5 + likely * 0.2)
|
| 527 |
-
return {
|
| 528 |
-
"documents": extracts,
|
| 529 |
-
"field_results": field_results,
|
| 530 |
-
"mismatches": mismatches,
|
| 531 |
-
"likely_mismatches": likely,
|
| 532 |
-
"consistency_risk_score": round(risk_score, 3),
|
| 533 |
-
"consistency_band": band(risk_score),
|
| 534 |
-
}
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
if __name__ == "__main__":
|
| 538 |
-
import sys
|
| 539 |
-
if len(sys.argv) < 2:
|
| 540 |
-
print("Usage: python forensics.py <file>")
|
| 541 |
-
sys.exit(1)
|
| 542 |
-
print(json.dumps(analyse_document(sys.argv[1]), indent=2, default=str))
|
|
|
|
| 38 |
import cv2
|
| 39 |
import fitz # PyMuPDF
|
| 40 |
import pytesseract
|
| 41 |
+
try:
|
| 42 |
+
import ai_detector
|
| 43 |
+
AI_DETECTOR_OK = True
|
| 44 |
+
except Exception:
|
| 45 |
+
AI_DETECTOR_OK = False
|
| 46 |
|
| 47 |
warnings.filterwarnings("ignore")
|
| 48 |
|
|
|
|
| 421 |
report["cnn_prediction"] = cnn
|
| 422 |
except Exception as e:
|
| 423 |
report["cnn_error"] = str(e)
|
| 424 |
+
# AI-generated content detector (FFT spectral analysis)
|
| 425 |
+
try:
|
| 426 |
+
if AI_DETECTOR_OK:
|
| 427 |
+
ai = ai_detector.detect_ai_generated(path)
|
| 428 |
+
report["ai_detector"] = ai
|
| 429 |
+
sub["ai_generated"] = ai["probability"]
|
| 430 |
+
# Blend lightly: AI-gen prob bumps risk up to +20%
|
| 431 |
+
s = 0.9 * s + 0.1 * ai["probability"] * 2.0
|
| 432 |
+
if ai["probability"] >= 0.6:
|
| 433 |
+
flags = flags + [f"AI-generated content suspected (prob {ai['probability']:.2f})"]
|
| 434 |
+
except Exception as e:
|
| 435 |
+
report["ai_detector_error"] = str(e)
|
| 436 |
insights = generate_insights(s, sub, flags + text_rules.get("flags", []))
|
| 437 |
report.update({"sub_scores": sub, "exif_flags": flags,
|
| 438 |
"text_rules": text_rules, **insights})
|
|
|
|
| 450 |
else:
|
| 451 |
report["type"] = "unsupported"
|
| 452 |
report["error"] = "extension " + ext + " not handled"
|
| 453 |
+
# Log to provenance ledger (tamper-evident hash chain)
|
| 454 |
+
try:
|
| 455 |
+
import provenance
|
| 456 |
+
provenance.log_analysis(report.get("file","unknown"),
|
| 457 |
+
report.get("sha256","-"),
|
| 458 |
+
report.get("risk_band","UNKNOWN"),
|
| 459 |
+
report.get("risk_score", -1.0),
|
| 460 |
+
extra={"type": report.get("type")})
|
| 461 |
+
except Exception as _e:
|
| 462 |
+
report["provenance_error"] = str(_e)
|
| 463 |
return report
|
| 464 |
|
| 465 |
|
|
|
|
| 549 |
}
|
| 550 |
# Aggregate risk
|
| 551 |
mismatches = sum(1 for r in field_results.values() if r["status"] == "mismatch")
|
| 552 |
+
li
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fraud_ring.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
fraud_ring.py - Cross-applicant fraud-ring detection.
|
| 3 |
+
|
| 4 |
+
Banks lose ~Rs 3,000 crore/year to organised application fraud
|
| 5 |
+
(RBI's own annual report). This module detects rings by:
|
| 6 |
+
|
| 7 |
+
1. Extracting identity signals from each applicant's documents
|
| 8 |
+
(name, DOB, address, phone, IFSC, account number, employer).
|
| 9 |
+
2. Building a similarity graph: nodes = applicants,
|
| 10 |
+
edges = shared signals (weighted by how many signals match).
|
| 11 |
+
3. Finding cliques and connected subgraphs above a similarity threshold.
|
| 12 |
+
Each subgraph of >= 3 applicants is a suspected fraud ring.
|
| 13 |
+
4. Rendering an interactive network graph with rings highlighted in red.
|
| 14 |
+
|
| 15 |
+
Public API:
|
| 16 |
+
extract_applicant_fields(path) - pulls identity fields from a doc
|
| 17 |
+
compare_applicants(a, b) - similarity score + shared signals
|
| 18 |
+
build_fraud_graph(applicants) - returns nx.Graph
|
| 19 |
+
detect_rings(G, min_size, threshold) - returns list of ring subgraphs
|
| 20 |
+
visualize_graph(G, rings, names) - returns matplotlib Figure
|
| 21 |
+
fraud_summary(G, rings, applicants) - structured summary for the UI
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import re
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from difflib import SequenceMatcher
|
| 27 |
+
from collections import defaultdict
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
import networkx as nx
|
| 31 |
+
import matplotlib.pyplot as plt
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ============================================================
|
| 35 |
+
# Field extraction
|
| 36 |
+
# ============================================================
|
| 37 |
+
|
| 38 |
+
NAME_RE = re.compile(r"(?:Name|Owner|Borrower|Holder|Account Holder|Applicant)\s*[:\-]\s*([A-Z][A-Z\s.]{2,40})", re.I)
|
| 39 |
+
DOB_RE = re.compile(r"(?:DOB|Date of Birth|Born)\s*[:\-]\s*(\d{1,2}[-/]\d{1,2}[-/]\d{2,4})", re.I)
|
| 40 |
+
ADDR_RE = re.compile(r"(?:Address|Village|Residence|City)\s*[:\-]\s*([A-Z0-9][A-Z0-9\s,.\-/]{3,80})", re.I)
|
| 41 |
+
PHONE_RE = re.compile(r"\b[6-9]\d{9}\b") # Indian mobile
|
| 42 |
+
IFSC_RE = re.compile(r"\b[A-Z]{4}0[A-Z0-9]{6}\b")
|
| 43 |
+
ACC_RE = re.compile(r"\b\d{9,18}\b")
|
| 44 |
+
EMP_RE = re.compile(r"(?:Employer|Company|Workplace)\s*[:\-]\s*([A-Z][A-Z0-9\s.&]{2,50})", re.I)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _norm(s):
|
| 48 |
+
return re.sub(r"\s+", " ", (s or "").strip().upper())
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def extract_applicant_fields(path):
|
| 52 |
+
"""
|
| 53 |
+
Extract identity signals from a document.
|
| 54 |
+
|
| 55 |
+
Returns dict with the standard set of fields. Missing fields are None.
|
| 56 |
+
Uses PDF text extraction for PDFs, OCR for images. If OCR isn't
|
| 57 |
+
available, image documents return mostly empty fields - the graph
|
| 58 |
+
builder handles that gracefully.
|
| 59 |
+
"""
|
| 60 |
+
text = ""
|
| 61 |
+
p = Path(path)
|
| 62 |
+
try:
|
| 63 |
+
if p.suffix.lower() == ".pdf":
|
| 64 |
+
import fitz
|
| 65 |
+
with fitz.open(p) as d:
|
| 66 |
+
text = "\n".join(page.get_text() for page in d)
|
| 67 |
+
else:
|
| 68 |
+
try:
|
| 69 |
+
import pytesseract
|
| 70 |
+
from PIL import Image
|
| 71 |
+
text = pytesseract.image_to_string(Image.open(p))
|
| 72 |
+
except Exception:
|
| 73 |
+
text = ""
|
| 74 |
+
except Exception:
|
| 75 |
+
text = ""
|
| 76 |
+
|
| 77 |
+
def first(rx, source=text):
|
| 78 |
+
m = rx.search(source) if hasattr(rx, "search") else None
|
| 79 |
+
if m:
|
| 80 |
+
try: return _norm(m.group(1))
|
| 81 |
+
except IndexError: return _norm(m.group(0))
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
fields = {
|
| 85 |
+
"file": str(p),
|
| 86 |
+
"name": first(NAME_RE),
|
| 87 |
+
"dob": first(DOB_RE),
|
| 88 |
+
"address": first(ADDR_RE),
|
| 89 |
+
"phone": PHONE_RE.search(text).group(0) if PHONE_RE.search(text) else None,
|
| 90 |
+
"ifsc": IFSC_RE.findall(text)[0] if IFSC_RE.findall(text) else None,
|
| 91 |
+
"account": ACC_RE.findall(text)[0] if ACC_RE.findall(text) else None,
|
| 92 |
+
"employer": first(EMP_RE),
|
| 93 |
+
"text_sample": text[:500],
|
| 94 |
+
}
|
| 95 |
+
return fields
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ============================================================
|
| 99 |
+
# Similarity scoring between two applicants
|
| 100 |
+
# ============================================================
|
| 101 |
+
|
| 102 |
+
def _string_sim(a, b):
|
| 103 |
+
if not a or not b:
|
| 104 |
+
return 0.0
|
| 105 |
+
return SequenceMatcher(None, _norm(a), _norm(b)).ratio()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _exact(a, b):
|
| 109 |
+
return bool(a) and bool(b) and _norm(a) == _norm(b)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def compare_applicants(a, b, name_thresh=0.85, addr_thresh=0.80):
|
| 113 |
+
"""
|
| 114 |
+
Compare two applicants' identity fields. Returns:
|
| 115 |
+
{
|
| 116 |
+
'score': float in [0, 1] - overall similarity
|
| 117 |
+
'shared': dict {field: bool} - which signals matched
|
| 118 |
+
'highlights': list of str - human-readable matched signals
|
| 119 |
+
}
|
| 120 |
+
"""
|
| 121 |
+
shared = {}
|
| 122 |
+
highlights = []
|
| 123 |
+
|
| 124 |
+
# Name (fuzzy)
|
| 125 |
+
name_sim = _string_sim(a.get("name"), b.get("name"))
|
| 126 |
+
shared["name"] = name_sim >= name_thresh
|
| 127 |
+
if shared["name"]:
|
| 128 |
+
highlights.append(f"Name: {a.get('name')}")
|
| 129 |
+
|
| 130 |
+
# DOB (exact)
|
| 131 |
+
shared["dob"] = _exact(a.get("dob"), b.get("dob"))
|
| 132 |
+
if shared["dob"]:
|
| 133 |
+
highlights.append(f"DOB: {a.get('dob')}")
|
| 134 |
+
|
| 135 |
+
# Address (fuzzy)
|
| 136 |
+
addr_sim = _string_sim(a.get("address"), b.get("address"))
|
| 137 |
+
shared["address"] = addr_sim >= addr_thresh
|
| 138 |
+
if shared["address"]:
|
| 139 |
+
highlights.append(f"Address: {a.get('address')}")
|
| 140 |
+
|
| 141 |
+
# Phone (exact)
|
| 142 |
+
shared["phone"] = _exact(a.get("phone"), b.get("phone"))
|
| 143 |
+
if shared["phone"]:
|
| 144 |
+
highlights.append(f"Phone: {a.get('phone')}")
|
| 145 |
+
|
| 146 |
+
# IFSC (exact) — same branch is mildly suspicious
|
| 147 |
+
shared["ifsc"] = _exact(a.get("ifsc"), b.get("ifsc"))
|
| 148 |
+
if shared["ifsc"]:
|
| 149 |
+
highlights.append(f"IFSC: {a.get('ifsc')}")
|
| 150 |
+
|
| 151 |
+
# Account (exact) — VERY suspicious (same bank account = same person)
|
| 152 |
+
shared["account"] = _exact(a.get("account"), b.get("account"))
|
| 153 |
+
if shared["account"]:
|
| 154 |
+
highlights.append(f"Account: {a.get('account')}")
|
| 155 |
+
|
| 156 |
+
# Employer (fuzzy)
|
| 157 |
+
emp_sim = _string_sim(a.get("employer"), b.get("employer"))
|
| 158 |
+
shared["employer"] = emp_sim >= 0.85
|
| 159 |
+
if shared["employer"]:
|
| 160 |
+
highlights.append(f"Employer: {a.get('employer')}")
|
| 161 |
+
|
| 162 |
+
# Weighted score — different fields have different fraud significance
|
| 163 |
+
weights = {"name": 0.10, "dob": 0.15, "address": 0.20, "phone": 0.20,
|
| 164 |
+
"ifsc": 0.05, "account": 0.25, "employer": 0.05}
|
| 165 |
+
score = sum(weights[k] for k, v in shared.items() if v)
|
| 166 |
+
|
| 167 |
+
return {
|
| 168 |
+
"score": round(score, 3),
|
| 169 |
+
"shared": shared,
|
| 170 |
+
"highlights": highlights,
|
| 171 |
+
"n_matches": sum(1 for v in shared.values() if v),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ============================================================
|
| 176 |
+
# Graph builder
|
| 177 |
+
# ============================================================
|
| 178 |
+
|
| 179 |
+
def build_fraud_graph(applicants, name_thresh=0.85, addr_thresh=0.80):
|
| 180 |
+
"""
|
| 181 |
+
applicants: list[dict] returned by extract_applicant_fields
|
| 182 |
+
Returns: nx.Graph with applicants as nodes (indexed 0..N-1)
|
| 183 |
+
and edges weighted by similarity score.
|
| 184 |
+
"""
|
| 185 |
+
G = nx.Graph()
|
| 186 |
+
for i, a in enumerate(applicants):
|
| 187 |
+
G.add_node(i,
|
| 188 |
+
file=a.get("file"),
|
| 189 |
+
name=a.get("name") or "(unknown)",
|
| 190 |
+
dob=a.get("dob"),
|
| 191 |
+
address=a.get("address"),
|
| 192 |
+
phone=a.get("phone"),
|
| 193 |
+
ifsc=a.get("ifsc"),
|
| 194 |
+
account=a.get("account"),
|
| 195 |
+
employer=a.get("employer"))
|
| 196 |
+
for i in range(len(applicants)):
|
| 197 |
+
for j in range(i + 1, len(applicants)):
|
| 198 |
+
cmp = compare_applicants(applicants[i], applicants[j],
|
| 199 |
+
name_thresh=name_thresh,
|
| 200 |
+
addr_thresh=addr_thresh)
|
| 201 |
+
if cmp["n_matches"] > 0:
|
| 202 |
+
G.add_edge(i, j,
|
| 203 |
+
weight=cmp["score"],
|
| 204 |
+
highlights=cmp["highlights"],
|
| 205 |
+
shared=cmp["shared"],
|
| 206 |
+
n_matches=cmp["n_matches"])
|
| 207 |
+
return G
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def detect_rings(G, min_size=3, edge_threshold=0.30):
|
| 211 |
+
"""
|
| 212 |
+
Find suspected fraud rings: connected components with >= min_size
|
| 213 |
+
applicants linked by edges above edge_threshold.
|
| 214 |
+
|
| 215 |
+
Returns: list of frozensets (applicant indices), sorted by size.
|
| 216 |
+
"""
|
| 217 |
+
H = G.copy()
|
| 218 |
+
H.remove_edges_from([(u, v) for u, v, d in G.edges(data=True)
|
| 219 |
+
if d.get("weight", 0) < edge_threshold])
|
| 220 |
+
rings = [frozenset(c) for c in nx.connected_components(H) if len(c) >= min_size]
|
| 221 |
+
rings.sort(key=len, reverse=True)
|
| 222 |
+
return rings
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# ============================================================
|
| 226 |
+
# Visualization
|
| 227 |
+
# ============================================================
|
| 228 |
+
|
| 229 |
+
def visualize_graph(G, rings=None, figsize=(11, 7), seed=42):
|
| 230 |
+
"""Render the fraud graph. Ring members in red, others in green."""
|
| 231 |
+
rings = rings or []
|
| 232 |
+
ring_members = set().union(*rings) if rings else set()
|
| 233 |
+
|
| 234 |
+
fig, ax = plt.subplots(figsize=figsize)
|
| 235 |
+
if len(G.nodes) == 0:
|
| 236 |
+
ax.text(0.5, 0.5, "No applicants to plot.",
|
| 237 |
+
ha="center", va="center", fontsize=14)
|
| 238 |
+
ax.axis("off")
|
| 239 |
+
return fig
|
| 240 |
+
|
| 241 |
+
pos = nx.spring_layout(G, seed=seed, k=0.8)
|
| 242 |
+
|
| 243 |
+
# Node colours
|
| 244 |
+
node_colors = ["#dc2626" if i in ring_members else "#16a34a" for i in G.nodes]
|
| 245 |
+
node_sizes = [900 if i in ring_members else 600 for i in G.nodes]
|
| 246 |
+
|
| 247 |
+
# Edge widths proportional to weight
|
| 248 |
+
edges = list(G.edges(data=True))
|
| 249 |
+
edge_widths = [1 + 5 * d.get("weight", 0) for u, v, d in edges]
|
| 250 |
+
edge_colors = ["#dc2626" if (u in ring_members and v in ring_members) else "#94a3b8"
|
| 251 |
+
for u, v, _ in edges]
|
| 252 |
+
|
| 253 |
+
nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=node_sizes,
|
| 254 |
+
edgecolors="black", linewidths=1.5, ax=ax)
|
| 255 |
+
nx.draw_networkx_edges(G, pos, width=edge_widths, edge_color=edge_colors,
|
| 256 |
+
alpha=0.7, ax=ax)
|
| 257 |
+
labels = {i: (G.nodes[i].get("name") or f"#{i}")[:15] for i in G.nodes}
|
| 258 |
+
nx.draw_networkx_labels(G, pos, labels, font_size=9, font_color="white",
|
| 259 |
+
font_weight="bold", ax=ax)
|
| 260 |
+
|
| 261 |
+
# Title bar
|
| 262 |
+
n_rings = len(rings)
|
| 263 |
+
largest = max((len(r) for r in rings), default=0)
|
| 264 |
+
title = (f"Cross-applicant fraud network | {len(G.nodes)} applicants "
|
| 265 |
+
f"| {n_rings} suspected ring{'s' if n_rings != 1 else ''}")
|
| 266 |
+
if largest:
|
| 267 |
+
title += f" | largest ring: {largest} applicants"
|
| 268 |
+
ax.set_title(title, fontsize=12, weight="bold")
|
| 269 |
+
ax.axis("off")
|
| 270 |
+
plt.tight_layout()
|
| 271 |
+
return fig
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def fraud_summary(G, rings, applicants):
|
| 275 |
+
"""Structured summary for the Streamlit UI."""
|
| 276 |
+
ring_data = []
|
| 277 |
+
for i, ring in enumerate(rings):
|
| 278 |
+
members = list(ring)
|
| 279 |
+
edges = [(u, v, d) for u, v, d in G.edges(data=True)
|
| 280 |
+
if u in ring and v in ring]
|
| 281 |
+
# Collect the most common shared signals
|
| 282 |
+
signal_counts = defaultdict(int)
|
| 283 |
+
for _, _, d in edges:
|
| 284 |
+
for k, v in d.get("shared", {}).items():
|
| 285 |
+
if v: signal_counts[k] += 1
|
| 286 |
+
top_signals = sorted(signal_counts.items(), key=lambda x: -x[1])[:3]
|
| 287 |
+
|
| 288 |
+
ring_data.append({
|
| 289 |
+
"ring_id": i + 1,
|
| 290 |
+
"size": len(ring),
|
| 291 |
+
"applicant_indices": members,
|
| 292 |
+
"applicant_names": [applicants[m].get("name") or f"#{m}" for m in members],
|
| 293 |
+
"applicant_files": [Path(applicants[m].get("file", "")).name for m in members],
|
| 294 |
+
"n_links": len(edges),
|
| 295 |
+
"top_shared_signals": top_signals,
|
| 296 |
+
"risk_band": "CRITICAL" if len(ring) >= 5 else "HIGH" if len(ring) >= 3 else "MEDIUM",
|
| 297 |
+
})
|
| 298 |
+
|
| 299 |
+
n_in_rings = len(set().union(*rings)) if rings else 0
|
| 300 |
+
return {
|
| 301 |
+
"n_applicants": len(applicants),
|
| 302 |
+
"n_rings": len(rings),
|
| 303 |
+
"n_applicants_in_rings": n_in_rings,
|
| 304 |
+
"largest_ring_size": max((len(r) for r in rings), default=0),
|
| 305 |
+
"rings": ring_data,
|
| 306 |
+
"fraud_risk_percentage": round(100 * n_in_rings / max(1, len(applicants)), 1),
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ============================================================
|
| 311 |
+
# Smoke test
|
| 312 |
+
# ============================================================
|
| 313 |
+
|
| 314 |
+
if __name__ == "__main__":
|
| 315 |
+
fake = [
|
| 316 |
+
{"file": "a.pdf", "name": "RAMESH KUMAR", "dob": "14-03-1985",
|
| 317 |
+
"address": "D-234 ALIPORE KOLKATA", "phone": "9876543210",
|
| 318 |
+
"ifsc": "SBIN0001234", "account": "78439336112", "employer": "ACME LTD"},
|
| 319 |
+
{"file": "b.pdf", "name": "SURESH KUMAR", "dob": "14-03-1985",
|
| 320 |
+
"address": "D-234 ALIPORE KOLKATA", "phone": "9876543210",
|
| 321 |
+
"ifsc": "HDFC0009999", "account": "78439336112", "employer": None},
|
| 322 |
+
{"file": "c.pdf", "name": "AMIT SHARMA", "dob": "22-07-1990",
|
| 323 |
+
"address": "D-234 ALIPORE KOLKATA", "phone": "9876543210",
|
| 324 |
+
"ifsc": None, "account": None, "employer": None},
|
| 325 |
+
{"file": "d.pdf", "name": "POOJA VERMA", "dob": "01-01-1995",
|
| 326 |
+
"address": "FLAT 12 BANDRA MUMBAI", "phone": "8765432100",
|
| 327 |
+
"ifsc": "ICIC0008888", "account": "12345678901", "employer": "XYZ CORP"},
|
| 328 |
+
]
|
| 329 |
+
G = build_fraud_graph(fake)
|
| 330 |
+
rings = detect_rings(G)
|
| 331 |
+
summary = fraud_summary(G, rings, fake)
|
| 332 |
+
print(f"Built graph: {len(G.nodes)} nodes, {len(G.edges)} edges")
|
| 333 |
+
print(f"Detected {len(rings)} rings")
|
| 334 |
+
for r in summary["rings"]:
|
| 335 |
+
print(f" Ring #{r['ring_id']} ({r['risk_band']}): {r['applicant_names']}")
|
| 336 |
+
print(f" Top signals: {r['top_shared_signals']}")
|
| 337 |
+
print(f"Fraud-risk percentage: {summary['fraud_risk_percentage']}%")
|
provenance.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
provenance.py - Tamper-evident audit ledger for DocSentry.
|
| 3 |
+
|
| 4 |
+
Every document analysis is logged as a record in a SQLite database. Each
|
| 5 |
+
record stores:
|
| 6 |
+
- timestamp
|
| 7 |
+
- document filename + SHA-256
|
| 8 |
+
- verdict band + risk score
|
| 9 |
+
- SHA-256 of the previous ledger entry (hash chain)
|
| 10 |
+
- record_hash: SHA-256 of (timestamp + doc hash + verdict + prev_hash)
|
| 11 |
+
|
| 12 |
+
The chain construction means that retroactively tampering with any past
|
| 13 |
+
record breaks the hash chain - giving DocSentry an audit trail that meets
|
| 14 |
+
RBI's tamper-evident record-retention requirements (Master Direction on
|
| 15 |
+
KYC, 2016, Para 67).
|
| 16 |
+
|
| 17 |
+
This is conceptually a baby blockchain: hash-linked records, immutable in
|
| 18 |
+
practice, verifiable in O(N).
|
| 19 |
+
|
| 20 |
+
Public API:
|
| 21 |
+
log_analysis(doc_path, sha256, risk_band, risk_score, extra)
|
| 22 |
+
fetch_ledger(limit=100)
|
| 23 |
+
verify_chain() - returns (ok: bool, broken_at: index)
|
| 24 |
+
chain_stats() - summary numbers for the UI
|
| 25 |
+
ledger_dataframe() - pandas DataFrame view
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
import os
|
| 29 |
+
import json
|
| 30 |
+
import sqlite3
|
| 31 |
+
import hashlib
|
| 32 |
+
from datetime import datetime
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
LEDGER_PATH = Path("provenance.db")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _conn():
|
| 39 |
+
LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
con = sqlite3.connect(str(LEDGER_PATH))
|
| 41 |
+
con.execute("""CREATE TABLE IF NOT EXISTS ledger (
|
| 42 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 43 |
+
ts TEXT NOT NULL,
|
| 44 |
+
doc_name TEXT NOT NULL,
|
| 45 |
+
doc_sha256 TEXT NOT NULL,
|
| 46 |
+
risk_band TEXT,
|
| 47 |
+
risk_score REAL,
|
| 48 |
+
extra_json TEXT,
|
| 49 |
+
prev_hash TEXT,
|
| 50 |
+
record_hash TEXT NOT NULL
|
| 51 |
+
)""")
|
| 52 |
+
con.commit()
|
| 53 |
+
return con
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _hash_record(ts, doc_sha256, risk_band, risk_score, prev_hash):
|
| 57 |
+
payload = f"{ts}|{doc_sha256}|{risk_band}|{risk_score}|{prev_hash}".encode()
|
| 58 |
+
return hashlib.sha256(payload).hexdigest()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def log_analysis(doc_path, sha256, risk_band, risk_score, extra=None):
|
| 62 |
+
"""Append a new entry to the provenance ledger and return the new row."""
|
| 63 |
+
ts = datetime.utcnow().isoformat() + "Z"
|
| 64 |
+
con = _conn()
|
| 65 |
+
cur = con.cursor()
|
| 66 |
+
cur.execute("SELECT record_hash FROM ledger ORDER BY id DESC LIMIT 1")
|
| 67 |
+
last = cur.fetchone()
|
| 68 |
+
prev_hash = last[0] if last else "GENESIS"
|
| 69 |
+
rec_hash = _hash_record(ts, sha256, risk_band, risk_score, prev_hash)
|
| 70 |
+
cur.execute("""INSERT INTO ledger (ts, doc_name, doc_sha256, risk_band,
|
| 71 |
+
risk_score, extra_json, prev_hash, record_hash)
|
| 72 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 73 |
+
(ts, Path(doc_path).name, sha256, risk_band, risk_score,
|
| 74 |
+
json.dumps(extra or {}, default=str), prev_hash, rec_hash))
|
| 75 |
+
con.commit()
|
| 76 |
+
new_id = cur.lastrowid
|
| 77 |
+
con.close()
|
| 78 |
+
return {"id": new_id, "ts": ts, "record_hash": rec_hash, "prev_hash": prev_hash}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def fetch_ledger(limit=100):
|
| 82 |
+
con = _conn()
|
| 83 |
+
rows = con.execute("""SELECT id, ts, doc_name, doc_sha256, risk_band,
|
| 84 |
+
risk_score, prev_hash, record_hash
|
| 85 |
+
FROM ledger ORDER BY id DESC LIMIT ?""", (limit,)).fetchall()
|
| 86 |
+
con.close()
|
| 87 |
+
return [{
|
| 88 |
+
"id": r[0], "ts": r[1], "doc_name": r[2],
|
| 89 |
+
"doc_sha256": r[3], "risk_band": r[4], "risk_score": r[5],
|
| 90 |
+
"prev_hash": r[6], "record_hash": r[7],
|
| 91 |
+
} for r in rows]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def verify_chain():
|
| 95 |
+
"""
|
| 96 |
+
Walk the ledger in order and verify each record_hash matches what
|
| 97 |
+
should have been computed from (ts, doc_sha256, risk_band, risk_score,
|
| 98 |
+
prev_hash). Returns (ok, broken_at).
|
| 99 |
+
"""
|
| 100 |
+
con = _conn()
|
| 101 |
+
rows = con.execute("""SELECT id, ts, doc_sha256, risk_band, risk_score,
|
| 102 |
+
prev_hash, record_hash FROM ledger ORDER BY id""").fetchall()
|
| 103 |
+
con.close()
|
| 104 |
+
prev = "GENESIS"
|
| 105 |
+
for r in rows:
|
| 106 |
+
rid, ts, doc_hash, band, score, stored_prev, stored_rec = r
|
| 107 |
+
if stored_prev != prev:
|
| 108 |
+
return False, rid
|
| 109 |
+
expected = _hash_record(ts, doc_hash, band, score, prev)
|
| 110 |
+
if expected != stored_rec:
|
| 111 |
+
return False, rid
|
| 112 |
+
prev = stored_rec
|
| 113 |
+
return True, None
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def chain_stats():
|
| 117 |
+
con = _conn()
|
| 118 |
+
n = con.execute("SELECT COUNT(*) FROM ledger").fetchone()[0]
|
| 119 |
+
bands = con.execute("SELECT risk_band, COUNT(*) FROM ledger GROUP BY risk_band").fetchall()
|
| 120 |
+
first = con.execute("SELECT ts FROM ledger ORDER BY id LIMIT 1").fetchone()
|
| 121 |
+
last = con.execute("SELECT ts FROM ledger ORDER BY id DESC LIMIT 1").fetchone()
|
| 122 |
+
con.close()
|
| 123 |
+
ok, broken_at = verify_chain()
|
| 124 |
+
return {
|
| 125 |
+
"n_records": n,
|
| 126 |
+
"first_ts": first[0] if first else None,
|
| 127 |
+
"last_ts": last[0] if last else None,
|
| 128 |
+
"by_band": dict(bands),
|
| 129 |
+
"chain_intact": ok,
|
| 130 |
+
"broken_at": broken_at,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def ledger_dataframe(limit=100):
|
| 135 |
+
"""Convenience: return ledger as a pandas DataFrame for Streamlit display."""
|
| 136 |
+
import pandas as pd
|
| 137 |
+
rows = fetch_ledger(limit=limit)
|
| 138 |
+
return pd.DataFrame(rows)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def clear_ledger():
|
| 142 |
+
"""Wipe the ledger (for demo resets only)."""
|
| 143 |
+
con = _conn()
|
| 144 |
+
con.execute("DELETE FROM ledger")
|
| 145 |
+
con.commit()
|
| 146 |
+
con.close()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
# Smoke test
|
| 151 |
+
clear_ledger()
|
| 152 |
+
log_analysis("doc1.pdf", "a" * 64, "LOW", 0.05)
|
| 153 |
+
log_analysis("doc2.pdf", "b" * 64, "MEDIUM", 0.42)
|
| 154 |
+
log_analysis("doc3.pdf", "c" * 64, "HIGH", 0.74)
|
| 155 |
+
log_analysis("doc4.pdf", "d" * 64, "CRITICAL", 0.91)
|
| 156 |
+
print("Stats:", chain_stats())
|
| 157 |
+
print("\nLast 4 records:")
|
| 158 |
+
for r in fetch_ledger(4):
|
| 159 |
+
print(f" #{r['id']} {r['ts']} {r['doc_name']:8s} {r['risk_band']:9s} "
|
| 160 |
+
f"hash={r['record_hash'][:12]}... prev={r['prev_hash'][:12]}...")
|
| 161 |
+
ok, broken = verify_chain()
|
| 162 |
+
print(f"\nChain verify: ok={ok}, broken_at={broken}")
|
| 163 |
+
# Simulate tampering
|
| 164 |
+
con = _conn()
|
| 165 |
+
con.execute("UPDATE ledger SET risk_band='LOW' WHERE id=3")
|
| 166 |
+
con.commit()
|
| 167 |
+
con.close()
|
| 168 |
+
ok, broken = verify_chain()
|
| 169 |
+
print(f"After tampering: ok={ok}, broken_at={broken}")
|
| 170 |
+
clear_ledger()
|
| 171 |
+
print("Ledger cleared.")
|
requirements.txt
CHANGED
|
@@ -14,3 +14,4 @@ python-dateutil
|
|
| 14 |
streamlit
|
| 15 |
reportlab
|
| 16 |
streamlit-drawable-canvas
|
|
|
|
|
|
| 14 |
streamlit
|
| 15 |
reportlab
|
| 16 |
streamlit-drawable-canvas
|
| 17 |
+
networkx
|
tampering.py
CHANGED
|
@@ -289,7 +289,8 @@ def detector_scorecard(image_path):
|
|
| 289 |
scores["Random Forest"] = {"score": ml["tamper_probability"],
|
| 290 |
"raw": ml["verdict"],
|
| 291 |
"what": "Learned forensic-feature blend"}
|
| 292 |
-
except Exception:
|
|
|
|
| 293 |
for v in scores.values():
|
| 294 |
v["caught"] = v["score"] >= 0.4
|
| 295 |
return scores
|
|
|
|
| 289 |
scores["Random Forest"] = {"score": ml["tamper_probability"],
|
| 290 |
"raw": ml["verdict"],
|
| 291 |
"what": "Learned forensic-feature blend"}
|
| 292 |
+
except Exception:
|
| 293 |
+
pass
|
| 294 |
for v in scores.values():
|
| 295 |
v["caught"] = v["score"] >= 0.4
|
| 296 |
return scores
|