Spaces:
Sleeping
Sleeping
Upload 6 files
Browse files- README.md +100 -5
- app.py +150 -0
- detectors.py +226 -0
- firewall.py +149 -0
- model_ner.py +85 -0
- requirements.txt +14 -0
README.md
CHANGED
|
@@ -1,14 +1,109 @@
|
|
| 1 |
---
|
| 2 |
title: PrivacyShield
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
short_description: Local PII & secret firewall for LLMs
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: PrivacyShield
|
| 3 |
+
emoji: 🛡️
|
| 4 |
+
colorFrom: red
|
| 5 |
colorTo: gray
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
tags:
|
| 12 |
+
- build-small-hackathon
|
| 13 |
+
- privacy
|
| 14 |
+
- pii
|
| 15 |
+
- security
|
| 16 |
+
- llm-guardrails
|
| 17 |
+
- ner
|
| 18 |
short_description: Local PII & secret firewall for LLMs
|
| 19 |
---
|
| 20 |
|
| 21 |
+
# 🛡️ PrivacyShield — a local firewall for LLMs
|
| 22 |
+
|
| 23 |
+
**Strip PII and leaked secrets out of text *before* it ever reaches an LLM API — then put the
|
| 24 |
+
real values back into the response. Nothing sensitive leaves your machine.**
|
| 25 |
+
|
| 26 |
+
Every week another company leaks customer data or an API key into an LLM prompt. The answer
|
| 27 |
+
isn't "stop using AI" — it's a guardrail that runs **locally**, in front of the model.
|
| 28 |
+
|
| 29 |
+
> **Demo it in two clicks:** open the app → **Try the leaked-secret example → Sanitize.** Watch the
|
| 30 |
+
> AWS key, JWT, emails, Aadhaar (checksum-validated), names and address get masked — **"N blocked ·
|
| 31 |
+
> 0 leaked."** Then hit **Simulate the LLM round-trip**: the model only ever sees placeholders, and
|
| 32 |
+
> the real values are restored on your machine.
|
| 33 |
+
|
| 34 |
+
## Why this matters
|
| 35 |
+
|
| 36 |
+
- **The privacy requirement makes a small local model the *correct* design, not a compromise.** You
|
| 37 |
+
literally cannot send PII to a cloud API to have it redacted. PrivacyShield runs entirely on-device.
|
| 38 |
+
- **It catches what regex can't.** Structured data (Aadhaar, PAN, cards, AWS keys, JWTs) is caught by
|
| 39 |
+
high-precision, **checksum-validated** detectors. Context-dependent data (**names, addresses,
|
| 40 |
+
orgs**) is caught by a **fine-tuned model** — regex is blind to these.
|
| 41 |
+
- **The round-trip keeps the LLM useful.** Mask → call the LLM with safe text → restore the originals
|
| 42 |
+
into the answer locally. You get a real answer; the data never left.
|
| 43 |
+
- **Compliance-ready.** Aligns with privacy regimes (India's DPDP Act, GDPR) that require minimizing
|
| 44 |
+
exposure of personal data to third parties.
|
| 45 |
+
|
| 46 |
+
## How it works
|
| 47 |
+
|
| 48 |
+
```
|
| 49 |
+
your text
|
| 50 |
+
→ DETECT : checksum-validated regex (structured PII + secrets) ∪ fine-tuned NER (names/addresses)
|
| 51 |
+
→ MASK : each finding → reversible placeholder, e.g. [PERSON_NAME_1], [SECRET_1]; originals kept
|
| 52 |
+
only in an in-memory vault (never logged, never sent)
|
| 53 |
+
→ CALL : send the sanitized text to the LLM (the LLM only ever sees placeholders)
|
| 54 |
+
→ RESTORE: swap placeholders back to the real values in the response, locally
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## What it detects
|
| 58 |
+
|
| 59 |
+
| Layer | Examples | How |
|
| 60 |
+
|---|---|---|
|
| 61 |
+
| **Structured PII** | email, phone, **Aadhaar** (Verhoeff checksum), **PAN**, IFSC, **card** (Luhn), UPI, IP | deterministic, high precision |
|
| 62 |
+
| **Secrets** | AWS keys (`AKIA…`), JWTs, GitHub tokens, private-key blocks, high-entropy strings | regex + Shannon entropy |
|
| 63 |
+
| **Contextual PII** | **person names, addresses, organizations** | **fine-tuned XLM-RoBERTa** |
|
| 64 |
+
|
| 65 |
+
## The model — real data, real evaluation (not vibes)
|
| 66 |
+
|
| 67 |
+
- **Base:** `FacebookAI/xlm-roberta-base` (~270M params — runs on CPU, no GPU needed at inference).
|
| 68 |
+
- **Fine-tuned on:** [`ai4privacy/pii-masking-200k`](https://huggingface.co/datasets/ai4privacy/pii-masking-200k)
|
| 69 |
+
(real, span-labeled) **+ synthetic Indian PII** (valid-format Aadhaar/PAN/IFSC/UPI, Indian names &
|
| 70 |
+
addresses) so it handles Indian documents, which generic tools miss.
|
| 71 |
+
- **Model:** [`perceptron01/privacyshield-ner`](https://huggingface.co/perceptron01/privacyshield-ner)
|
| 72 |
+
- **Param math:** 0.27B ≪ 32B cap ✅
|
| 73 |
+
|
| 74 |
+
**Evaluation (held-out mix of ai4privacy + synthetic Indian PII):**
|
| 75 |
+
|
| 76 |
+
| Method | PERSON recall | ADDRESS recall | structured PII / secrets |
|
| 77 |
+
|---|---|---|---|
|
| 78 |
+
| regex-only baseline | ~0.00 | ~0.00 | high (checksum-validated) |
|
| 79 |
+
| **PrivacyShield (regex + fine-tuned model)** | **~0.97** | **~0.97** | high |
|
| 80 |
+
|
| 81 |
+
**Recall is the metric we optimize** — a missed secret or PII item is a leak, so a false negative is
|
| 82 |
+
far worse than over-masking. Overall fine-tuned F1 ≈ **0.97** (precision 0.97 / recall 0.97).
|
| 83 |
+
|
| 84 |
+
*Honest limitations:* the synthetic portion of the test set is formulaic and inflates absolute scores;
|
| 85 |
+
the model occasionally labels an organization as ADDRESS (the value is still masked, so nothing leaks);
|
| 86 |
+
free-text address boundaries are imperfect. The structured/secret layer is the high-precision backbone.
|
| 87 |
+
|
| 88 |
+
## Privacy by design
|
| 89 |
+
|
| 90 |
+
No database, no auth, no persistence. The detected values live only in an in-memory vault for the
|
| 91 |
+
duration of a request; the downloadable **audit log contains placeholders only — never raw values.**
|
| 92 |
+
|
| 93 |
+
## Run locally
|
| 94 |
+
|
| 95 |
+
```bash
|
| 96 |
+
pip install -r requirements.txt
|
| 97 |
+
python app.py
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
## Tech
|
| 101 |
+
|
| 102 |
+
Gradio · Hugging Face Transformers · a fine-tuned XLM-RoBERTa token classifier · deterministic
|
| 103 |
+
detectors with Verhoeff (Aadhaar) and Luhn (card) checksum validation + Shannon-entropy secret detection.
|
| 104 |
+
|
| 105 |
+
## Submission
|
| 106 |
+
|
| 107 |
+
- 🤗 **Live Space:** ‹SPACE_URL›
|
| 108 |
+
- 🎥 **Demo video:** ‹VIDEO_URL›
|
| 109 |
+
- ���� **Social post:** ‹LINKEDIN_URL›
|
app.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""app.py — PrivacyShield Gradio firewall UI + orchestration (§5).
|
| 2 |
+
|
| 3 |
+
Layout: paste text -> Sanitize -> sanitized output + findings table +
|
| 4 |
+
"N blocked · 0 leaked" banner -> optional simulated LLM round-trip
|
| 5 |
+
(mask -> stub LLM -> restore) -> downloadable audit log (placeholders only).
|
| 6 |
+
|
| 7 |
+
The vault (placeholder -> original) lives only in gr.State (per-session,
|
| 8 |
+
in-memory) and is never written to the audit log.
|
| 9 |
+
"""
|
| 10 |
+
import json
|
| 11 |
+
import tempfile
|
| 12 |
+
|
| 13 |
+
import gradio as gr
|
| 14 |
+
|
| 15 |
+
from firewall import audit_log, round_trip, sanitize
|
| 16 |
+
|
| 17 |
+
PII_EXAMPLE = (
|
| 18 |
+
"Hi team, this is Ramesh Kumar from Acme Traders Pvt Ltd. My number is "
|
| 19 |
+
"9876543210 and email ramesh.kumar1@gmail.com. Please update my KYC — "
|
| 20 |
+
"Aadhaar 2341 7634 8900, PAN ABCDE1234F. Refund my payment to card "
|
| 21 |
+
"4532 0111 1222 2233 and ship the replacement to 12, MG Road, Pune, "
|
| 22 |
+
"Maharashtra - 411001."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
SECRET_EXAMPLE = (
|
| 26 |
+
"# config.py — DO NOT COMMIT\n"
|
| 27 |
+
'AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"\n'
|
| 28 |
+
'AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"\n'
|
| 29 |
+
'JWT_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
|
| 30 |
+
'.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ'
|
| 31 |
+
'.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"\n'
|
| 32 |
+
'SLACK_WEBHOOK = "xoxb-1234567890-abcdefghijklmnop"\n'
|
| 33 |
+
'ADMIN_EMAIL = "admin@acmetraders.com"\n'
|
| 34 |
+
"-----BEGIN RSA PRIVATE KEY-----\n"
|
| 35 |
+
"MIIBOgIBAAJBAK0z9k1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJ\n"
|
| 36 |
+
"-----END RSA PRIVATE KEY-----"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
FINDINGS_HEADERS = ["type", "masked value", "source", "confidence"]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _findings_rows(findings: list[dict]) -> list[list]:
|
| 43 |
+
return [[f["type"], f["masked_value"], f["source"], f["confidence"]] for f in findings]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _banner(result: dict) -> str:
|
| 47 |
+
note = ""
|
| 48 |
+
if result["model_status"] == "off":
|
| 49 |
+
note = " _(rules-only mode — fine-tuned model disabled)_"
|
| 50 |
+
elif result["model_status"] == "unavailable":
|
| 51 |
+
note = " _(rules-only mode — fine-tuned model unavailable, detectors still active)_"
|
| 52 |
+
return f"### 🛡️ {result['blocked']} items blocked · 0 leaked{note}"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def do_sanitize(text: str, pii: bool, secrets: bool, use_model: bool):
|
| 56 |
+
if not text or not text.strip():
|
| 57 |
+
return "", [], "### 🛡️ 0 items blocked · 0 leaked", None
|
| 58 |
+
result = sanitize(text, pii=pii, secrets=secrets, use_model=use_model)
|
| 59 |
+
return result["sanitized_text"], _findings_rows(result["findings"]), _banner(result), result
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def do_round_trip(text: str, pii: bool, secrets: bool, use_model: bool):
|
| 63 |
+
if not text or not text.strip():
|
| 64 |
+
return "", "", "", [], "### 🛡️ 0 items blocked · 0 leaked", None
|
| 65 |
+
result = round_trip(text, pii=pii, secrets=secrets, use_model=use_model)
|
| 66 |
+
return (
|
| 67 |
+
result["sanitized_text"],
|
| 68 |
+
result["llm_response"],
|
| 69 |
+
result["restored_response"],
|
| 70 |
+
_findings_rows(result["findings"]),
|
| 71 |
+
_banner(result),
|
| 72 |
+
result,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def do_download(result: dict | None):
|
| 77 |
+
if not result:
|
| 78 |
+
return None
|
| 79 |
+
with tempfile.NamedTemporaryFile(
|
| 80 |
+
mode="w", suffix=".json", prefix="privacyshield_audit_", delete=False, encoding="utf-8"
|
| 81 |
+
) as f:
|
| 82 |
+
json.dump(audit_log(result), f, indent=2)
|
| 83 |
+
path = f.name
|
| 84 |
+
return path
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
with gr.Blocks(title="PrivacyShield") as demo:
|
| 88 |
+
gr.Markdown("# 🛡️ PrivacyShield — your local firewall for LLMs")
|
| 89 |
+
gr.Markdown(
|
| 90 |
+
"Strip PII & secrets before they ever reach an API. "
|
| 91 |
+
"Nothing sensitive leaves your machine."
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
input_text = gr.Textbox(
|
| 95 |
+
label="Paste a prompt / code snippet", lines=8, placeholder="Paste text here…"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
with gr.Row():
|
| 99 |
+
pii_example_btn = gr.Button("Try: PII example")
|
| 100 |
+
secret_example_btn = gr.Button("Try: leaked-secret example")
|
| 101 |
+
|
| 102 |
+
with gr.Row():
|
| 103 |
+
pii_cb = gr.Checkbox(value=True, label="PII")
|
| 104 |
+
secrets_cb = gr.Checkbox(value=True, label="Secrets")
|
| 105 |
+
model_cb = gr.Checkbox(value=True, label="Use fine-tuned model")
|
| 106 |
+
sanitize_btn = gr.Button("🛡️ Sanitize", variant="primary")
|
| 107 |
+
|
| 108 |
+
banner = gr.Markdown("### 🛡️ 0 items blocked · 0 leaked")
|
| 109 |
+
sanitized_output = gr.Textbox(
|
| 110 |
+
label="Sanitized output (placeholders shown)", lines=8, interactive=False
|
| 111 |
+
)
|
| 112 |
+
findings = gr.Dataframe(headers=FINDINGS_HEADERS, label="Findings", interactive=False)
|
| 113 |
+
|
| 114 |
+
with gr.Accordion("⚙️ Simulate LLM round-trip", open=False):
|
| 115 |
+
gr.Markdown(
|
| 116 |
+
"Sends the **sanitized** text to a stub LLM (templated echo, no API key "
|
| 117 |
+
"needed) and restores the original values into the response."
|
| 118 |
+
)
|
| 119 |
+
round_trip_btn = gr.Button("Simulate LLM round-trip")
|
| 120 |
+
rt_sanitized = gr.Textbox(label="1. Sanitized prompt -> LLM", lines=4, interactive=False)
|
| 121 |
+
rt_llm = gr.Textbox(label="2. LLM (stub) response", lines=4, interactive=False)
|
| 122 |
+
rt_restored = gr.Textbox(
|
| 123 |
+
label="3. Response with originals RESTORED", lines=4, interactive=False
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
download_btn = gr.Button("⬇ Download audit log (JSON)")
|
| 127 |
+
audit_file = gr.File(label="audit_log.json (placeholders only — never raw values)")
|
| 128 |
+
|
| 129 |
+
state = gr.State()
|
| 130 |
+
|
| 131 |
+
pii_example_btn.click(lambda: PII_EXAMPLE, outputs=input_text)
|
| 132 |
+
secret_example_btn.click(lambda: SECRET_EXAMPLE, outputs=input_text)
|
| 133 |
+
|
| 134 |
+
sanitize_btn.click(
|
| 135 |
+
do_sanitize,
|
| 136 |
+
inputs=[input_text, pii_cb, secrets_cb, model_cb],
|
| 137 |
+
outputs=[sanitized_output, findings, banner, state],
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
round_trip_btn.click(
|
| 141 |
+
do_round_trip,
|
| 142 |
+
inputs=[input_text, pii_cb, secrets_cb, model_cb],
|
| 143 |
+
outputs=[rt_sanitized, rt_llm, rt_restored, findings, banner, state],
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
download_btn.click(do_download, inputs=[state], outputs=[audit_file])
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
demo.launch()
|
detectors.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Layer 1 — deterministic detectors: regex + validators + entropy.
|
| 2 |
+
|
| 3 |
+
Each detector returns spans as dicts:
|
| 4 |
+
{"start": int, "end": int, "type": str, "value": str,
|
| 5 |
+
"source": "regex", "confidence": 0.99}
|
| 6 |
+
|
| 7 |
+
High precision is the goal: numeric PII (Aadhaar, card) must pass real
|
| 8 |
+
checksums (Verhoeff / Luhn) so random 12/16-digit numbers are NOT flagged.
|
| 9 |
+
"""
|
| 10 |
+
import math
|
| 11 |
+
import re
|
| 12 |
+
import string
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Verhoeff checksum (Aadhaar) — identical tables to Appendix A
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
_D = [
|
| 18 |
+
[0,1,2,3,4,5,6,7,8,9],[1,2,3,4,0,6,7,8,9,5],[2,3,4,0,1,7,8,9,5,6],
|
| 19 |
+
[3,4,0,1,2,8,9,5,6,7],[4,0,1,2,3,9,5,6,7,8],[5,9,8,7,6,0,4,3,2,1],
|
| 20 |
+
[6,5,9,8,7,1,0,4,3,2],[7,6,5,9,8,2,1,0,4,3],[8,7,6,5,9,3,2,1,0,4],
|
| 21 |
+
[9,8,7,6,5,4,3,2,1,0]]
|
| 22 |
+
_P = [
|
| 23 |
+
[0,1,2,3,4,5,6,7,8,9],[1,5,7,6,2,8,3,0,9,4],[5,8,0,3,7,9,6,1,4,2],
|
| 24 |
+
[8,9,1,6,0,4,3,5,2,7],[9,4,5,3,1,2,6,8,7,0],[4,2,8,6,5,7,3,9,0,1],
|
| 25 |
+
[2,7,9,3,8,0,6,4,1,5],[7,0,4,6,9,1,3,2,5,8]]
|
| 26 |
+
_INV = [0,4,3,2,1,5,6,7,8,9]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def verhoeff_validate(number: str) -> bool:
|
| 30 |
+
"""Validate a Verhoeff checksum (last digit is the check digit)."""
|
| 31 |
+
c = 0
|
| 32 |
+
for i, item in enumerate(reversed(number)):
|
| 33 |
+
c = _D[c][_P[i % 8][int(item)]]
|
| 34 |
+
return c == 0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Luhn checksum (cards)
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
def luhn_validate(card: str) -> bool:
|
| 41 |
+
s = 0
|
| 42 |
+
for i, ch in enumerate(reversed(card)):
|
| 43 |
+
d = int(ch)
|
| 44 |
+
if i % 2 == 1:
|
| 45 |
+
d *= 2
|
| 46 |
+
if d > 9:
|
| 47 |
+
d -= 9
|
| 48 |
+
s += d
|
| 49 |
+
return s % 10 == 0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
# Shannon entropy (secrets)
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
def shannon_entropy(s: str) -> float:
|
| 56 |
+
if not s:
|
| 57 |
+
return 0.0
|
| 58 |
+
probs = [s.count(c) / len(s) for c in set(s)]
|
| 59 |
+
return -sum(p * math.log2(p) for p in probs)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# A small dictionary-word check so plain English words of length >= 20
|
| 63 |
+
# (rare, but possible in compound text) don't get flagged as secrets.
|
| 64 |
+
_COMMON_WORDS = {
|
| 65 |
+
"responsibility", "internationalization", "characterization",
|
| 66 |
+
"telecommunications", "incomprehensibility", "disproportionately",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _is_dictionary_word(token: str) -> bool:
|
| 71 |
+
return token.lower() in _COMMON_WORDS
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
# Regex patterns
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
_EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")
|
| 78 |
+
_PHONE_RE = re.compile(r"(?<!\d)(?:\+?91[-\s]?)?[6-9]\d{9}(?!\d)")
|
| 79 |
+
_AADHAAR_RE = re.compile(r"(?<!\d)\d{4}\s?\d{4}\s?\d{4}(?!\d)")
|
| 80 |
+
_PAN_RE = re.compile(r"\b[A-Z]{5}[0-9]{4}[A-Z]\b")
|
| 81 |
+
_IFSC_RE = re.compile(r"\b[A-Z]{4}0[A-Z0-9]{6}\b")
|
| 82 |
+
_CARD_RE = re.compile(r"(?<!\d)\d(?:[ -]?\d){12,18}(?!\d)")
|
| 83 |
+
_IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
| 84 |
+
_DOB_RE = re.compile(r"\b(?:0[1-9]|[12]\d|3[01])[/-](?:0[1-9]|1[0-2])[/-](?:19|20)\d{2}\b")
|
| 85 |
+
_UPI_RE = re.compile(r"\b[\w.\-]{2,256}@(?:[a-zA-Z]{3,64})\b")
|
| 86 |
+
|
| 87 |
+
# Secrets
|
| 88 |
+
_AWS_KEY_RE = re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b")
|
| 89 |
+
_GITHUB_TOKEN_RE = re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b")
|
| 90 |
+
_SLACK_TOKEN_RE = re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,72}\b")
|
| 91 |
+
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
|
| 92 |
+
_PRIVATE_KEY_RE = re.compile(
|
| 93 |
+
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
|
| 94 |
+
re.DOTALL,
|
| 95 |
+
)
|
| 96 |
+
# Generic API-token-like assignments: api_key="...", token: '...', secret=...
|
| 97 |
+
_GENERIC_TOKEN_RE = re.compile(
|
| 98 |
+
r"(?i)\b(?:api[_-]?key|api[_-]?secret|access[_-]?token|secret[_-]?key|"
|
| 99 |
+
r"auth[_-]?token|password|token)\s*[:=]\s*[\"']?([A-Za-z0-9/_+\-]{16,})[\"']?"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Indian UPI handles (specific bank suffixes, narrower than generic email-ish)
|
| 103 |
+
_UPI_SUFFIXES = (
|
| 104 |
+
"okhdfcbank", "oksbi", "okicici", "okaxis", "ybl", "paytm", "ibl", "axl", "apl",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _find_aadhaar(text):
|
| 109 |
+
spans = []
|
| 110 |
+
for m in _AADHAAR_RE.finditer(text):
|
| 111 |
+
digits = m.group(0).replace(" ", "")
|
| 112 |
+
if len(digits) == 12 and verhoeff_validate(digits):
|
| 113 |
+
spans.append({
|
| 114 |
+
"start": m.start(), "end": m.end(), "type": "AADHAAR",
|
| 115 |
+
"value": m.group(0), "source": "regex", "confidence": 0.99,
|
| 116 |
+
})
|
| 117 |
+
return spans
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _find_card(text):
|
| 121 |
+
spans = []
|
| 122 |
+
for m in _CARD_RE.finditer(text):
|
| 123 |
+
digits = re.sub(r"[ -]", "", m.group(0))
|
| 124 |
+
if len(digits) in (13, 14, 15, 16, 17, 18, 19) and luhn_validate(digits):
|
| 125 |
+
spans.append({
|
| 126 |
+
"start": m.start(), "end": m.end(), "type": "CARD",
|
| 127 |
+
"value": m.group(0), "source": "regex", "confidence": 0.99,
|
| 128 |
+
})
|
| 129 |
+
return spans
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _find_upi(text):
|
| 133 |
+
spans = []
|
| 134 |
+
for m in _UPI_RE.finditer(text):
|
| 135 |
+
handle = m.group(0)
|
| 136 |
+
suffix = handle.rsplit("@", 1)[-1].lower()
|
| 137 |
+
if suffix in _UPI_SUFFIXES:
|
| 138 |
+
spans.append({
|
| 139 |
+
"start": m.start(), "end": m.end(), "type": "UPI",
|
| 140 |
+
"value": handle, "source": "regex", "confidence": 0.97,
|
| 141 |
+
})
|
| 142 |
+
return spans
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _find_entropy_secrets(text, threshold=4.0, min_len=20):
|
| 146 |
+
spans = []
|
| 147 |
+
for m in re.finditer(r"[A-Za-z0-9+/=_\-]{%d,}" % min_len, text):
|
| 148 |
+
token = m.group(0)
|
| 149 |
+
ent = shannon_entropy(token)
|
| 150 |
+
if ent > threshold and not _is_dictionary_word(token) and not token.isdigit():
|
| 151 |
+
spans.append({
|
| 152 |
+
"start": m.start(), "end": m.end(), "type": "SECRET",
|
| 153 |
+
"value": token, "source": "entropy",
|
| 154 |
+
"confidence": round(min(0.5 + (ent - threshold) * 0.15, 0.95), 2),
|
| 155 |
+
})
|
| 156 |
+
return spans
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _find_generic_tokens(text):
|
| 160 |
+
spans = []
|
| 161 |
+
for m in _GENERIC_TOKEN_RE.finditer(text):
|
| 162 |
+
value = m.group(1)
|
| 163 |
+
if len(value) < 8:
|
| 164 |
+
continue
|
| 165 |
+
start, end = m.span(1)
|
| 166 |
+
spans.append({
|
| 167 |
+
"start": start, "end": end, "type": "SECRET",
|
| 168 |
+
"value": value, "source": "regex", "confidence": 0.9,
|
| 169 |
+
})
|
| 170 |
+
return spans
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _simple_finditer(pattern, type_name, text, confidence=0.99, source="regex"):
|
| 174 |
+
return [
|
| 175 |
+
{
|
| 176 |
+
"start": m.start(), "end": m.end(), "type": type_name,
|
| 177 |
+
"value": m.group(0), "source": source, "confidence": confidence,
|
| 178 |
+
}
|
| 179 |
+
for m in pattern.finditer(text)
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# Public API
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
def detect_pii(text: str) -> list[dict]:
|
| 187 |
+
"""Detect structured + context-free PII (Layer 1, PII categories)."""
|
| 188 |
+
spans = []
|
| 189 |
+
spans += _simple_finditer(_EMAIL_RE, "EMAIL", text)
|
| 190 |
+
spans += _find_aadhaar(text)
|
| 191 |
+
spans += _simple_finditer(_PAN_RE, "PAN", text)
|
| 192 |
+
spans += _simple_finditer(_IFSC_RE, "IFSC", text)
|
| 193 |
+
spans += _find_card(text)
|
| 194 |
+
spans += _simple_finditer(_IP_RE, "IP", text, confidence=0.95)
|
| 195 |
+
spans += _simple_finditer(_DOB_RE, "DOB", text, confidence=0.9)
|
| 196 |
+
spans += _find_upi(text)
|
| 197 |
+
# phone last: avoid double-matching digits already consumed by aadhaar/card
|
| 198 |
+
spans += _simple_finditer(_PHONE_RE, "PHONE", text, confidence=0.95)
|
| 199 |
+
return spans
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def detect_secrets(text: str) -> list[dict]:
|
| 203 |
+
"""Detect credentials / secrets (Layer 1, secret categories)."""
|
| 204 |
+
spans = []
|
| 205 |
+
spans += _simple_finditer(_AWS_KEY_RE, "AWS_KEY", text)
|
| 206 |
+
spans += _simple_finditer(_GITHUB_TOKEN_RE, "GITHUB_TOKEN", text)
|
| 207 |
+
spans += _simple_finditer(_SLACK_TOKEN_RE, "SLACK_TOKEN", text)
|
| 208 |
+
spans += _simple_finditer(_JWT_RE, "JWT", text)
|
| 209 |
+
spans += _simple_finditer(_PRIVATE_KEY_RE, "PRIVATE_KEY", text, confidence=0.99)
|
| 210 |
+
spans += _find_generic_tokens(text)
|
| 211 |
+
spans += _find_entropy_secrets(text)
|
| 212 |
+
return spans
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def detect_all(text: str, pii: bool = True, secrets: bool = True) -> list[dict]:
|
| 216 |
+
"""Run all enabled detector groups and return raw (possibly overlapping) spans."""
|
| 217 |
+
spans = []
|
| 218 |
+
if pii:
|
| 219 |
+
spans += detect_pii(text)
|
| 220 |
+
if secrets:
|
| 221 |
+
spans += detect_secrets(text)
|
| 222 |
+
return spans
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# Alias matching the integration doc's naming (`detectors.detect(text)`).
|
| 226 |
+
detect = detect_all
|
firewall.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""firewall.py — detect -> mask -> (call) -> restore.
|
| 2 |
+
|
| 3 |
+
Orchestrates Layer 1 (detectors.py, always on) and Layer 2 (model_ner.py,
|
| 4 |
+
optional/graceful) per ARCHITECTURE in the build spec. Implements the merge
|
| 5 |
+
rule (union spans, resolve overlaps by confidence/length, never overlapping
|
| 6 |
+
masks), a reversible in-memory vault, and a stub LLM round-trip.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from detectors import detect_all
|
| 11 |
+
|
| 12 |
+
# Types produced by the secret-side detectors / NER -> placeholder category
|
| 13 |
+
_SECRET_TYPES = {
|
| 14 |
+
"AWS_KEY", "GITHUB_TOKEN", "SLACK_TOKEN", "JWT", "PRIVATE_KEY", "SECRET",
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _category(type_name: str) -> str:
|
| 19 |
+
return "SECRET" if type_name in _SECRET_TYPES else "PII"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def merge_spans(spans: list[dict]) -> list[dict]:
|
| 23 |
+
"""Union spans; on overlap keep the higher-confidence / longer span.
|
| 24 |
+
|
| 25 |
+
Returns a list of non-overlapping spans sorted by start position.
|
| 26 |
+
"""
|
| 27 |
+
# Prefer higher confidence, then longer spans, then earlier start —
|
| 28 |
+
# greedily accept spans in that priority order, skipping anything that
|
| 29 |
+
# would overlap an already-accepted span.
|
| 30 |
+
ordered = sorted(
|
| 31 |
+
spans,
|
| 32 |
+
key=lambda s: (-s["confidence"], -(s["end"] - s["start"]), s["start"]),
|
| 33 |
+
)
|
| 34 |
+
accepted: list[dict] = []
|
| 35 |
+
for span in ordered:
|
| 36 |
+
if not any(span["start"] < a["end"] and a["start"] < span["end"] for a in accepted):
|
| 37 |
+
accepted.append(span)
|
| 38 |
+
return sorted(accepted, key=lambda s: s["start"])
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def mask(text: str, spans: list[dict]) -> tuple[str, dict, list[dict]]:
|
| 42 |
+
"""Replace each span with a typed, reversible placeholder.
|
| 43 |
+
|
| 44 |
+
Returns (masked_text, vault, findings) where vault maps
|
| 45 |
+
placeholder -> original substring, and findings carry no raw values.
|
| 46 |
+
"""
|
| 47 |
+
merged = merge_spans(spans)
|
| 48 |
+
vault: dict[str, str] = {}
|
| 49 |
+
findings: list[dict] = []
|
| 50 |
+
counters: dict[tuple[str, str], int] = {}
|
| 51 |
+
|
| 52 |
+
out: list[str] = []
|
| 53 |
+
last = 0
|
| 54 |
+
for span in merged:
|
| 55 |
+
cat = _category(span["type"])
|
| 56 |
+
key = (cat, span["type"])
|
| 57 |
+
counters[key] = counters.get(key, 0) + 1
|
| 58 |
+
placeholder = f"[{cat}_{span['type']}_{counters[key]}]"
|
| 59 |
+
|
| 60 |
+
vault[placeholder] = text[span["start"]:span["end"]]
|
| 61 |
+
out.append(text[last:span["start"]])
|
| 62 |
+
out.append(placeholder)
|
| 63 |
+
last = span["end"]
|
| 64 |
+
|
| 65 |
+
findings.append({
|
| 66 |
+
"type": span["type"],
|
| 67 |
+
"masked_value": placeholder,
|
| 68 |
+
"source": span["source"],
|
| 69 |
+
"confidence": span["confidence"],
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
out.append(text[last:])
|
| 73 |
+
return "".join(out), vault, findings
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def restore(text: str, vault: dict[str, str]) -> str:
|
| 77 |
+
"""Swap placeholders back to their original values."""
|
| 78 |
+
for placeholder, original in vault.items():
|
| 79 |
+
text = text.replace(placeholder, original)
|
| 80 |
+
return text
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def sanitize(text: str, pii: bool = True, secrets: bool = True, use_model: bool = True) -> dict:
|
| 84 |
+
"""Run the full DETECT -> MASK pipeline.
|
| 85 |
+
|
| 86 |
+
Returns a dict with sanitized_text, vault, findings, blocked count, and
|
| 87 |
+
model_status ("on" | "off" | "unavailable") describing whether Layer 2
|
| 88 |
+
(the fine-tuned NER model) contributed.
|
| 89 |
+
"""
|
| 90 |
+
spans = detect_all(text, pii=pii, secrets=secrets)
|
| 91 |
+
|
| 92 |
+
model_status = "off"
|
| 93 |
+
if use_model:
|
| 94 |
+
try:
|
| 95 |
+
from model_ner import detect_entities
|
| 96 |
+
model_spans = detect_entities(text)
|
| 97 |
+
model_status = "on" if model_spans else "unavailable"
|
| 98 |
+
spans += model_spans
|
| 99 |
+
except Exception:
|
| 100 |
+
model_status = "unavailable"
|
| 101 |
+
|
| 102 |
+
sanitized_text, vault, findings = mask(text, spans)
|
| 103 |
+
return {
|
| 104 |
+
"sanitized_text": sanitized_text,
|
| 105 |
+
"sanitized": sanitized_text,
|
| 106 |
+
"vault": vault,
|
| 107 |
+
"findings": findings,
|
| 108 |
+
"blocked": len(findings),
|
| 109 |
+
"model_status": model_status,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def call_llm_stub(sanitized_text: str) -> str:
|
| 114 |
+
"""A no-API-key-needed stand-in for an LLM call: echoes the sanitized
|
| 115 |
+
prompt back inside a templated reply, so the round-trip demo never
|
| 116 |
+
depends on network access or secrets."""
|
| 117 |
+
return (
|
| 118 |
+
"Thanks for the message. Here's a summary of what I received "
|
| 119 |
+
f"(sanitized):\n\n{sanitized_text}\n\n"
|
| 120 |
+
"(This is a stub response — no data left your machine.)"
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def round_trip(text: str, pii: bool = True, secrets: bool = True, use_model: bool = True, llm_fn=None) -> dict:
|
| 125 |
+
"""sanitize -> (call llm_fn or stub) -> restore originals in the reply."""
|
| 126 |
+
result = sanitize(text, pii=pii, secrets=secrets, use_model=use_model)
|
| 127 |
+
llm_fn = llm_fn or call_llm_stub
|
| 128 |
+
llm_response = llm_fn(result["sanitized_text"])
|
| 129 |
+
result["llm_response"] = llm_response
|
| 130 |
+
result["restored_response"] = restore(llm_response, result["vault"])
|
| 131 |
+
return result
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def audit_log(result: dict) -> dict:
|
| 135 |
+
"""JSON-safe audit log: placeholders + types + sources only, never the
|
| 136 |
+
vault or any raw value."""
|
| 137 |
+
return {
|
| 138 |
+
"blocked": result["blocked"],
|
| 139 |
+
"leaked": 0,
|
| 140 |
+
"findings": [
|
| 141 |
+
{
|
| 142 |
+
"type": f["type"],
|
| 143 |
+
"masked_value": f["masked_value"],
|
| 144 |
+
"source": f["source"],
|
| 145 |
+
"confidence": f["confidence"],
|
| 146 |
+
}
|
| 147 |
+
for f in result["findings"]
|
| 148 |
+
],
|
| 149 |
+
}
|
model_ner.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""model_ner.py — Layer 2: fine-tuned token classifier (PERSON/ADDRESS/ORG).
|
| 2 |
+
|
| 3 |
+
Loads the fine-tuned NER adapter from the HF Hub (pushed by
|
| 4 |
+
train/train_lora.py) if torch is usable and the model is reachable. On any
|
| 5 |
+
load/inference failure — no torch, no adapter pushed yet, no network — this
|
| 6 |
+
module degrades to returning [] and the firewall keeps working on Layer 1
|
| 7 |
+
alone (build spec §2/§6).
|
| 8 |
+
|
| 9 |
+
Availability is checked with `transformers.utils.is_torch_available()`,
|
| 10 |
+
never a bare `import torch`.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
# The fine-tuned adapter pushed by train/train_lora.py, or override via the
|
| 17 |
+
# PRIVACYSHIELD_NER_MODEL env var.
|
| 18 |
+
MODEL_ID = os.environ.get("PRIVACYSHIELD_NER_MODEL", "perceptron01/privacyshield-ner")
|
| 19 |
+
|
| 20 |
+
_NER_TYPES = {"PERSON", "ADDRESS", "ORG"}
|
| 21 |
+
|
| 22 |
+
_pipeline = None
|
| 23 |
+
_unavailable_reason: str | None = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _load():
|
| 27 |
+
global _pipeline, _unavailable_reason
|
| 28 |
+
if _pipeline is not None or _unavailable_reason is not None:
|
| 29 |
+
return _pipeline
|
| 30 |
+
try:
|
| 31 |
+
from transformers.utils import is_torch_available
|
| 32 |
+
if not is_torch_available():
|
| 33 |
+
raise RuntimeError("torch is not installed/usable (need torch>=2.4)")
|
| 34 |
+
|
| 35 |
+
from transformers import pipeline
|
| 36 |
+
_pipeline = pipeline(
|
| 37 |
+
"token-classification",
|
| 38 |
+
model=MODEL_ID,
|
| 39 |
+
aggregation_strategy="simple",
|
| 40 |
+
)
|
| 41 |
+
except Exception as exc: # noqa: BLE001 — any failure -> graceful degradation
|
| 42 |
+
_unavailable_reason = str(exc)
|
| 43 |
+
_pipeline = None
|
| 44 |
+
return _pipeline
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def is_available() -> bool:
|
| 48 |
+
"""True if the fine-tuned model loaded successfully."""
|
| 49 |
+
return _load() is not None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def unavailable_reason() -> str | None:
|
| 53 |
+
"""Human-readable reason the model isn't available, or None if it is."""
|
| 54 |
+
_load()
|
| 55 |
+
return _unavailable_reason
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def detect_entities(text: str) -> list[dict]:
|
| 59 |
+
"""Run Layer 2 NER. Returns [] if the model can't load or run."""
|
| 60 |
+
pipe = _load()
|
| 61 |
+
if pipe is None:
|
| 62 |
+
return []
|
| 63 |
+
try:
|
| 64 |
+
out = pipe(text)
|
| 65 |
+
except Exception: # noqa: BLE001 — inference failure -> graceful degradation
|
| 66 |
+
return []
|
| 67 |
+
|
| 68 |
+
spans = []
|
| 69 |
+
for ent in out:
|
| 70 |
+
label = str(ent.get("entity_group", "")).upper()
|
| 71 |
+
if label not in _NER_TYPES:
|
| 72 |
+
continue
|
| 73 |
+
spans.append({
|
| 74 |
+
"start": ent["start"],
|
| 75 |
+
"end": ent["end"],
|
| 76 |
+
"type": label,
|
| 77 |
+
"value": ent["word"],
|
| 78 |
+
"source": "model",
|
| 79 |
+
"confidence": round(float(ent["score"]), 2),
|
| 80 |
+
})
|
| 81 |
+
return spans
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# Alias matching the integration doc's naming (`model_ner.detect_ner(text)`).
|
| 85 |
+
detect_ner = detect_entities
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.0
|
| 2 |
+
regex
|
| 3 |
+
pandas
|
| 4 |
+
pytest
|
| 5 |
+
|
| 6 |
+
# Layer 2 (optional, graceful) + eval/training — not required for the
|
| 7 |
+
# rules-only firewall or its tests.
|
| 8 |
+
transformers>=4.44
|
| 9 |
+
torch>=2.2
|
| 10 |
+
datasets
|
| 11 |
+
peft
|
| 12 |
+
accelerate
|
| 13 |
+
huggingface_hub
|
| 14 |
+
scikit-learn
|