perceptron01 commited on
Commit
e431b8d
·
verified ·
1 Parent(s): b7421ac

Upload 6 files

Browse files
Files changed (6) hide show
  1. README.md +100 -5
  2. app.py +150 -0
  3. detectors.py +226 -0
  4. firewall.py +149 -0
  5. model_ner.py +85 -0
  6. requirements.txt +14 -0
README.md CHANGED
@@ -1,14 +1,109 @@
1
  ---
2
  title: PrivacyShield
3
- emoji: 🌍
4
- colorFrom: pink
5
  colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.18.0
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
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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