BentoUniAcc commited on
Commit
fd7251d
·
verified ·
1 Parent(s): 9f29ecf

MiMo-7B PDF injection detector: CPU/GGUF runtime, verbatim Part A+B logic

Browse files
Files changed (7) hide show
  1. README.md +101 -6
  2. app.py +225 -0
  3. corpus_text.py +226 -0
  4. mimo.py +240 -0
  5. neighbours.py +105 -0
  6. requirements.txt +23 -0
  7. test_fidelity.py +73 -0
README.md CHANGED
@@ -1,13 +1,108 @@
1
  ---
2
- title: Mimo Injection Detector
3
- emoji: 🏢
4
  colorFrom: yellow
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.22.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: PDF Injection Detector (MiMo-7B)
3
+ emoji: 🔍
4
  colorFrom: yellow
5
+ colorTo: gray
6
  sdk: gradio
 
 
7
  app_file: app.py
8
  pinned: false
9
+ license: mit
10
+ short_description: MiMo-7B finds payloads hidden inside PDF files
11
  ---
12
 
13
+ # PDF Injection Detector MiMo-7B on free CPU
14
+
15
+ Upload a PDF. It is rendered to text with the extractor that built the project corpus, the regions
16
+ carrying structural markers are ranked, and **MiMo-7B-RL** reads the most promising ones and says
17
+ whether a payload is hidden there — naming the family and quoting the substring that convinced it.
18
+
19
+ It is a coursework artefact built on a synthetic corpus of 1,100 PDFs carrying harmless
20
+ EICAR/AMTSO/WICAR/RANSIM test markers. **It is not a general malware scanner.**
21
+
22
+ ## The three repos this is built on
23
+
24
+ | Repo | What this Space takes from it |
25
+ |---|---|
26
+ | [Generated_Injected_PDFs_HARMLESS](https://huggingface.co/datasets/Cyber-security-final-project/Generated_Injected_PDFs_HARMLESS) | The 12 injection families and their structural signatures — the definition of what an attack looks like |
27
+ | [HARMLESS_Synthetic_Injected_PDFs_EDA](https://huggingface.co/datasets/Cyber-security-final-project/HARMLESS_Synthetic_Injected_PDFs_EDA) | `build_skeleton`, `mask_leaks` and `payload_window` — how a PDF becomes the text a model reads |
28
+ | [Evaluation_of_OpenSource_Models…](https://huggingface.co/datasets/Cyber-security-final-project/Evaluation_of_OpenSource_Models_for_PDF_Injection_Recognition) | Part A's embedding index and winning configuration; Part B's prompt, prefill and parser |
29
+
30
+ The prompt, the MiMo prefill, the brace-counting parser and the whole text-extraction path are
31
+ quoted **verbatim** from those notebooks. That is the correctness argument for quoting Part B's
32
+ scores here at all: change how the text is extracted or how the question is asked, and the
33
+ published numbers stop describing this program.
34
+
35
+ ## Fitting inside a free Space
36
+
37
+ A free Space is 2 vCPUs and 16 GB of RAM, with no GPU. Three consequences, all of them visible in
38
+ the interface rather than hidden:
39
+
40
+ **MiMo runs on the CPU as a GGUF.** Part B ran the BF16 checkpoint quantised to 4-bit NF4 by
41
+ `bitsandbytes`, which requires CUDA. Here the same base model runs as
42
+ [`quantflex/MiMo-7B-RL-nomtp-Q4_K_M.gguf`](https://huggingface.co/quantflex/MiMo-7B-RL-nomtp-GGUF)
43
+ (4.7 GB) through `llama.cpp`. That build has MiMo's multi-token-prediction layers removed, because
44
+ `llama.cpp` cannot load them — MTP is a speculative-decoding accelerator that the ordinary forward
45
+ pass does not use, so greedy output should be unaffected, but it is a real difference and it is
46
+ stated rather than buried. **Read F1 0.945 as the figure for the configuration Part B measured,
47
+ not as a measurement of this Space.**
48
+
49
+ **It triages instead of scanning everything.** MiMo reads a 3,000-character window in roughly two
50
+ minutes on 2 vCPUs, and a real PDF has dozens of windows. So the same marker alternation that
51
+ located the payload in the corpus is run over the whole skeleton, every hit becomes a candidate
52
+ window with the identical ±1,500-character shape, overlapping ones are merged, and the most
53
+ marker-dense go to the model first. **The ranking decides reading order, never the verdict.** The
54
+ report always says how many marked regions were left unread, so "clean" never overstates itself.
55
+
56
+ A file with no marker anywhere yields exactly one candidate — the head of the document — which is
57
+ byte-identical to what the corpus builder produced for a *clean* file. The triage only knows the
58
+ twelve families this project generated, so a payload shaped like none of them is triaged as if the
59
+ file were clean and MiMo sees the head of the file. That is a real hole, and the interface says so.
60
+
61
+ **Nothing is downloaded until it is needed.** The page comes up first; the 4.7 GB GGUF, the
62
+ 550 MB embedding model and the 3 MB index arrive on the first scan and are cached after that.
63
+
64
+ ## The numbers
65
+
66
+ On the 1,100-document corpus Part B measured, MiMo-7B-RL scored:
67
+
68
+ | | |
69
+ |---|---|
70
+ | F1 | **0.945** |
71
+ | precision | 0.988 |
72
+ | recall | 0.906 |
73
+ | names the family correctly | 43.3% of files it caught |
74
+ | false alarms | 10 of 200 clean files (5%) |
75
+ | unparsable answers | 155 of 1,100 |
76
+
77
+ **A detector that calls every file malicious scores F1 0.900 on this corpus**, because 82% of it is
78
+ injected. Read 0.945 against 0.900, not against zero — it is a 5% relative improvement on doing no
79
+ work at all. Gemma-2-9B scored 0.969 and is the actual Part B winner; MiMo is used here because it
80
+ is ungated, needs no token, and is 2.6× faster, which on a CPU is the difference between usable
81
+ and not.
82
+
83
+ Two limits worth stating plainly:
84
+
85
+ - **The family is a suggestion, not a verdict** — right 43% of the time. The nearest known corpus
86
+ files are shown beside it so the two can disagree in public.
87
+ - **The nearest-file lookup is weak on purpose to report.** Part A's winning embedder reaches
88
+ precision@5 of 35.6% against a 6.8% random baseline: fewer than 2 of the 5 files returned are the
89
+ same kind of attack. Far better than chance, and not good. It is labelled *resemblance*, never
90
+ *identification*.
91
+
92
+ ## The files
93
+
94
+ | File | What it does |
95
+ |---|---|
96
+ | `app.py` | The Gradio interface and the document-level report. No detection logic. |
97
+ | `corpus_text.py` | PDF bytes → skeleton → candidate windows. Everything above `Triage` is verbatim from the EDA notebook. |
98
+ | `mimo.py` | The prompt, the prefill, the parser (verbatim from Part B) and the llama.cpp runtime. |
99
+ | `neighbours.py` | Part A's embedding index and the nearest-neighbour lookup, with a provenance assertion. |
100
+
101
+ `neighbours.check_provenance()` asserts the embedder repo, prefix, dimension, normalisation and
102
+ input column against Part A's own `part_a_results.json` before any lookup runs — a query embedded
103
+ with the wrong model lands in a different space and returns meaningless neighbours silently, with
104
+ no error anywhere.
105
+
106
+ ## Secrets
107
+
108
+ None. Every model used here is ungated.
app.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF Injection Detector - MiMo-7B on a free CPU Space.
3
+
4
+ Upload a PDF, and this reads it the way the corpus was read, picks the parts most worth looking at,
5
+ and asks MiMo-7B-RL whether a payload is hidden in them.
6
+
7
+ `app.py` holds the interface and the aggregation. It contains no detection logic of its own: the
8
+ text extraction lives in `corpus_text.py`, the prompt and parser in `mimo.py`, the embedding lookup
9
+ in `neighbours.py`, and each of those is quoted from the notebook that measured it.
10
+ """
11
+
12
+ import traceback
13
+
14
+ import gradio as gr
15
+
16
+ import corpus_text
17
+ import mimo
18
+ import neighbours
19
+
20
+ # Measured in Part B on a T4. Kept here to be shown next to the app's own timings, because the two
21
+ # are wildly different and pretending otherwise would be the misleading thing.
22
+ PART_B = {"f1": 0.945, "precision": 0.988, "recall": 0.906, "family_acc": 0.433,
23
+ "false_alarm_rate": 0.05, "unparsable": 155, "n": 1100}
24
+
25
+ # A free Space is 2 vCPUs. A 7B model in Q4_K_M reads a 3,000-character window in roughly this
26
+ # long there - measured loosely, and shown as a range because it is a range.
27
+ SECONDS_PER_WINDOW = 130
28
+
29
+ MAX_WINDOWS = 6
30
+
31
+ WINDOW_COLUMNS = ["#", "where in the skeleton", "markers", "verdict", "family", "evidence"]
32
+
33
+
34
+ def fmt_eta(n_windows: int) -> str:
35
+ lo = n_windows * SECONDS_PER_WINDOW * 0.6 / 60
36
+ hi = n_windows * SECONDS_PER_WINDOW * 1.5 / 60
37
+ return f"{lo:.0f}-{hi:.0f} minutes"
38
+
39
+
40
+ def triage(path):
41
+ """Extract and rank, without loading any model. Runs in under a second, so it runs on upload."""
42
+ if not path:
43
+ return None, "Upload a PDF to see what will be read."
44
+
45
+ data = open(path, "rb").read()
46
+ skeleton, truncated, dropped = corpus_text.build_skeleton(data)
47
+ candidates = corpus_text.candidate_windows(skeleton)
48
+ families = corpus_text.detect_markers(data)
49
+
50
+ lines = [
51
+ f"**{len(data):,} bytes** on disk, rendered to a **{len(skeleton):,}-character skeleton**"
52
+ + (f" (truncated to the {corpus_text.SKELETON_CHAR_BUDGET:,}-character budget)" if truncated else "")
53
+ + (f", {dropped} binary stream(s) dropped." if dropped else "."),
54
+ ]
55
+ if candidates[0]["is_head"]:
56
+ lines.append(
57
+ "**No structural marker found anywhere in the file.** The head of the document will be "
58
+ "read instead — which is exactly what a *clean* file in the corpus looks like. Note "
59
+ "that the triage only recognises the twelve families this project generated, so a "
60
+ "payload shaped like none of them lands here too.")
61
+ else:
62
+ lines.append(
63
+ f"**{len(candidates)} candidate region(s)** carry a marker. "
64
+ f"Structural signatures present in the raw file: `{'`, `'.join(families) or 'none'}`.")
65
+ lines.append(
66
+ "_Those signatures decide **reading order only**. The verdict is MiMo's alone — the "
67
+ "regex has been wrong about a file before and will be again._")
68
+ return (skeleton, candidates), "\n\n".join(lines)
69
+
70
+
71
+ def on_upload(path, n_windows):
72
+ state, summary = triage(path)
73
+ if state is None:
74
+ return state, summary
75
+ n = min(int(n_windows), len(state[1]))
76
+ return state, summary + (
77
+ f"\n\nMiMo will read **{n} of {len(state[1])}** region(s): about **{fmt_eta(n)}** on this "
78
+ f"free CPU Space, plus a one-off download the first time anyone uses it.")
79
+
80
+
81
+ def run(path, n_windows, want_neighbours, progress=gr.Progress()):
82
+ """Score the top windows and build the report. Yields so the page updates as answers arrive."""
83
+ if not path:
84
+ yield "Upload a PDF first.", [], [], ""
85
+ return
86
+
87
+ progress(0, desc="reading the PDF")
88
+ state, _ = triage(path)
89
+ skeleton, candidates = state
90
+ chosen = candidates[:int(n_windows)]
91
+
92
+ rows, results, log = [], [], []
93
+ for i, win in enumerate(chosen):
94
+ progress(i / len(chosen), desc=f"MiMo reading region {i + 1} of {len(chosen)}")
95
+ try:
96
+ r = mimo.judge(win["text"], progress=lambda m: progress(i / len(chosen), desc=m))
97
+ except Exception as e:
98
+ log.append(f"region {i + 1} failed: {type(e).__name__}: {e}")
99
+ continue
100
+
101
+ results.append((win, r))
102
+ where = "head of document" if win["is_head"] else f"chars {win['start']:,}-{win['end']:,}"
103
+ verdict = ("PAYLOAD" if r["pred_injected"] else "clean") + (
104
+ "" if r["parse_ok"] else " (unreadable answer)")
105
+ rows.append([i + 1, where, ", ".join(win["families"]) or "-", verdict,
106
+ r["pred_family"], (r["evidence"] or "-")[:160]])
107
+ log.append(f"--- region {i + 1} ({where}, prompt via {r['prompt_route']}) ---\n{r['raw']}")
108
+ yield building(chosen, candidates, results), rows, [], "\n\n".join(log)
109
+
110
+ report = building(chosen, candidates, results, done=True)
111
+
112
+ nb_rows = []
113
+ if want_neighbours and results:
114
+ progress(0.95, desc="embedding and looking up the corpus")
115
+ flagged = next((w for w, r in results if r["pred_injected"]), None)
116
+ query = flagged or chosen[0]
117
+ try:
118
+ neighbours.check_provenance()
119
+ nb_rows = neighbours.neighbour_rows(query["text"], k=5)
120
+ basis = ("the first flagged region" if flagged else
121
+ "the first region read (nothing was flagged)")
122
+ report += (f"\n\n### Nearest files in the corpus\n\nEmbedded from **{basis}** with "
123
+ f"Part A's winning configuration. Precision@5 on this index is **35.6%** "
124
+ f"against a 6.8% random baseline: fewer than 2 of the 5 below are the same "
125
+ f"kind of attack. Read it as *resemblance*, not as identification.")
126
+ except Exception as e:
127
+ report += (f"\n\n### Nearest files in the corpus\n\nUnavailable: "
128
+ f"`{type(e).__name__}: {e}`")
129
+ log.append(traceback.format_exc())
130
+
131
+ yield report, rows, nb_rows, "\n\n".join(log)
132
+
133
+
134
+ def building(chosen, candidates, results, done=False) -> str:
135
+ """The document-level report. One verdict from several window verdicts, and the arithmetic."""
136
+ if not results:
137
+ return "Reading…" if not done else "Nothing was read."
138
+
139
+ hits = [(w, r) for w, r in results if r["pred_injected"]]
140
+ unread = len(candidates) - len(chosen)
141
+ unparsed = sum(1 for _, r in results if not r["parse_ok"])
142
+
143
+ if hits:
144
+ fams = sorted({r["pred_family"] for _, r in hits if r["pred_family"] != "none"})
145
+ head = (f"## Payload found\n\nMiMo flagged **{len(hits)} of the {len(results)} region(s) "
146
+ f"it read**.")
147
+ if fams:
148
+ head += (f" It named the family as **{', '.join(fams)}** — correct 43% of the time in "
149
+ f"Part B, so treat it as a suggestion.")
150
+ else:
151
+ head += " It did not commit to a family."
152
+ else:
153
+ head = (f"## Nothing found\n\nMiMo read **{len(results)} region(s)** and flagged none of "
154
+ f"them.")
155
+
156
+ caveats = []
157
+ if unread > 0:
158
+ caveats.append(f"**{unread} marked region(s) were not read** — the free CPU tier reads a "
159
+ f"few regions, not all of them. 'Clean' here means 'clean in what was "
160
+ f"read'.")
161
+ if unparsed:
162
+ caveats.append(f"{unparsed} answer(s) could not be parsed and count as *not injected*, "
163
+ f"exactly as Part B scored them (155 of 1,100 there).")
164
+ if not done:
165
+ caveats.append("_Still reading…_")
166
+
167
+ body = head
168
+ if caveats:
169
+ body += "\n\n" + "\n\n".join("- " + c for c in caveats)
170
+ body += (f"\n\n---\n\n**On the corpus Part B measured**, MiMo scored F1 {PART_B['f1']}, "
171
+ f"precision {PART_B['precision']}, recall {PART_B['recall']}, on {PART_B['n']:,} "
172
+ f"documents that were {100 - 200 / 11:.0f}% injected — where a detector that flags "
173
+ f"everything without reading it scores F1 0.900. Read 0.945 against 0.900, not "
174
+ f"against zero.\n\n_{mimo.MODEL_CAVEAT}_")
175
+ return body
176
+
177
+
178
+ INTRO = """
179
+ # PDF Injection Detector — MiMo-7B
180
+
181
+ Upload a PDF. It is rendered to text with the extractor that built the corpus, the regions carrying
182
+ structural markers are ranked, and **MiMo-7B-RL** reads the most promising ones and says whether a
183
+ payload is hidden there — with the substring that convinced it.
184
+
185
+ This is a coursework artefact built on a synthetic corpus of 1,100 PDFs carrying harmless
186
+ EICAR/AMTSO/WICAR/RANSIM test markers. **It is not a general malware scanner**, and real malware
187
+ does not announce itself the way these samples do.
188
+
189
+ It runs on a **free CPU Space**, so MiMo reads roughly one region every two minutes. That is the
190
+ whole reason it triages instead of scanning everything.
191
+ """
192
+
193
+
194
+ with gr.Blocks(title="PDF Injection Detector") as demo:
195
+ gr.Markdown(INTRO)
196
+ state = gr.State()
197
+
198
+ with gr.Row():
199
+ with gr.Column(scale=1):
200
+ pdf = gr.File(label="PDF", file_types=[".pdf"], type="filepath")
201
+ n_windows = gr.Slider(1, MAX_WINDOWS, value=3, step=1,
202
+ label="Regions for MiMo to read",
203
+ info="Each one costs a couple of minutes on free CPU.")
204
+ want_nb = gr.Checkbox(value=True, label="Also show the nearest files in the corpus",
205
+ info="Adds a one-off 550 MB embedding-model download.")
206
+ go = gr.Button("Check this PDF", variant="primary")
207
+ plan = gr.Markdown("Upload a PDF to see what will be read.")
208
+
209
+ with gr.Column(scale=2):
210
+ with gr.Tab("Report"):
211
+ report = gr.Markdown()
212
+ with gr.Tab("Regions read"):
213
+ window_table = gr.Dataframe(headers=WINDOW_COLUMNS, wrap=True, interactive=False)
214
+ with gr.Tab("Nearest corpus files"):
215
+ nb_table = gr.Dataframe(headers=neighbours.NEIGHBOUR_COLUMNS, interactive=False)
216
+ with gr.Tab("What MiMo actually said"):
217
+ raw = gr.Textbox(lines=22, show_copy_button=True, interactive=False,
218
+ info="The prompt route and the untouched generation per region.")
219
+
220
+ for ev in (pdf.change, n_windows.change):
221
+ ev(on_upload, [pdf, n_windows], [state, plan])
222
+ go.click(run, [pdf, n_windows, want_nb], [report, window_table, nb_table, raw])
223
+
224
+ if __name__ == "__main__":
225
+ demo.queue(max_size=8).launch()
corpus_text.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage 1 - turning an uploaded PDF into the exact kind of string the corpus was built from.
3
+
4
+ Everything in the first block is **lifted verbatim from the EDA notebook that built the corpus**
5
+ (`HARMLESS_Synthetic_Injected_PDFs_EDA/Final_project_V7_EDA.ipynb`, cells 88-90 and 107). That is
6
+ not tidiness, it is the correctness argument for this whole Space: Part A's embedding index and
7
+ Part B's F1 of 0.945 were both measured on `payload_window` strings produced by exactly this code.
8
+ Extract the text even slightly differently and those published numbers stop describing this app.
9
+
10
+ Do not "improve" anything above the `Triage` heading.
11
+ """
12
+
13
+ import re
14
+ import zlib
15
+
16
+ import numpy as np
17
+
18
+ # ---------------------------------------------------------------------------------------------
19
+ # Verbatim from the corpus build - EDA cell 88
20
+ # ---------------------------------------------------------------------------------------------
21
+
22
+ STREAM_RE = re.compile(rb"(stream\r?\n)(.*?)(endstream)", re.S)
23
+
24
+ INJECTION_MARKERS = {
25
+ "javascript_injection": [rb"/S\s*/JavaScript", rb"/JS\s*\("],
26
+ "cross_site_scripting": [rb"<script>", rb"fetch\("],
27
+ "ssrf": [rb"169\.254\.169\.254", rb"/latest/meta-data"],
28
+ "object_action_injection": [rb"/S\s*/Launch", rb"cmd\.exe"],
29
+ "llm_prompt_injection": [rb"IGNORE PREVIOUS INSTRUCTIONS", rb"LLM-INJECT"],
30
+ "shellcode_embedded_exe": [rb"application#2Fx-msdownload", rb"MZ.{0,20}\\x90\\x90"],
31
+ "polyglot_file": [rb"PK\\x03\\x04", rb"POLYGLOT ZIP\+PDF"],
32
+ "dde_template_injection": [rb"remote\.dotm\?dde=", rb"=cmd\|"],
33
+ "steganographic_payload": [rb"LSB-STEGO"],
34
+ "ransomware_simulation": [rb"RANSIM TEST"],
35
+ "xfa_acroform_injection": [rb"/Type\s*/XFA", rb"xfa:data"],
36
+ "uri_redirect_phishing": [rb"phishing\.\w+\.test"],
37
+ }
38
+ FRAMEWORK_MARKERS = ["AMTSO", "WICAR", "OWASP", "AtomicRedTeam", "Metasploit",
39
+ "Glasswall", "mindcrypt", "RanSim", "RANSIM", "custom"]
40
+ BASE_PAYLOAD_MARKERS = ["EICAR-STANDARD-ANTIVIRUS-TEST-FILE", "AMTSO-POTENTIALLY-UNWANTED-TEST-FILE",
41
+ "WICAR-BROWSER-TEST-PAYLOAD", "RANSIM-KNOWBE4-ENCRYPTION-SIMULATION"]
42
+
43
+ # Brand / test-file identifiers a model could string-match on without reasoning about PDF
44
+ # structure. The structural markers above are deliberately NOT masked: those ARE the attack, not a
45
+ # giveaway label. Masking them would delete the thing the model is supposed to find.
46
+ LEAK_STRINGS = sorted(
47
+ set(FRAMEWORK_MARKERS) | set(BASE_PAYLOAD_MARKERS)
48
+ | {"EICAR", "RANSIM", "eicar-standard-antivirus-test-file",
49
+ r"X5O!P%@AP[4\PZX54(P^)7CC)7}$"},
50
+ key=len, reverse=True) # longest first: "EICAR-STANDARD-..." masks before "EICAR"
51
+ LEAK_RE = re.compile("|".join(re.escape(s) for s in LEAK_STRINGS), re.IGNORECASE)
52
+
53
+ # ---------------------------------------------------------------------------------------------
54
+ # Verbatim from the corpus build - EDA cell 89
55
+ # ---------------------------------------------------------------------------------------------
56
+
57
+ STREAM_BODY_CAP = 4_096 # chars kept from any single stream body
58
+ SKELETON_CHAR_BUDGET = 120_000 # ~30k tokens
59
+ HEAD_SHARE = 0.45 # of the budget; the rest is the tail window
60
+ SCAN_WINDOW = 8_000_000 # bytes scanned from each end for markers
61
+
62
+ CLEAN_RE = re.compile(r"[^\x20-\x7e\n]")
63
+ SPACES_RE = re.compile(r"[ ]{4,}")
64
+
65
+
66
+ def _printable_frac(chunk: bytes, sample: int = 200_000) -> float:
67
+ """Fraction of bytes that are ordinary printable ASCII."""
68
+ if not chunk:
69
+ return 1.0
70
+ arr = np.frombuffer(chunk[:sample], dtype=np.uint8)
71
+ ok = ((arr >= 32) & (arr < 127)) | (arr == 9) | (arr == 10) | (arr == 13)
72
+ return float(ok.mean())
73
+
74
+
75
+ def build_skeleton(data: bytes):
76
+ """Render a PDF as payload-preserving text. Returns (skeleton, was_truncated, n_binary_dropped)."""
77
+ dropped = 0
78
+
79
+ def replace(match):
80
+ nonlocal dropped
81
+ opener, body, closer = match.group(1), match.group(2), match.group(3)
82
+ try: # most streams are FlateDecode
83
+ inflated = zlib.decompress(body)
84
+ if _printable_frac(inflated) > 0.6:
85
+ return opener + inflated[:STREAM_BODY_CAP] + b"\n" + closer
86
+ except zlib.error:
87
+ pass
88
+ if _printable_frac(body) > 0.6: # already plain text
89
+ return opener + body[:STREAM_BODY_CAP] + closer
90
+ dropped += 1 # genuinely binary (an image)
91
+ return opener + b"<<BINARY %d bytes>>" % len(body) + closer
92
+
93
+ text = STREAM_RE.sub(replace, data).decode("latin-1")
94
+
95
+ # Truncate FIRST, then clean: no point running two character-class substitutions over 76
96
+ # million characters only to discard 99.8% of the result.
97
+ truncated = len(text) > SKELETON_CHAR_BUDGET
98
+ if truncated:
99
+ # Head AND tail. The two insertion strategies put payloads at opposite ends of the file,
100
+ # so a plain head truncation would lose most of them.
101
+ head_n = int(SKELETON_CHAR_BUDGET * HEAD_SHARE)
102
+ tail_n = SKELETON_CHAR_BUDGET - head_n
103
+ elided = len(text) - SKELETON_CHAR_BUDGET
104
+ text = (text[:head_n * 2]
105
+ + f"\n<<... {elided} characters elided ...>>\n"
106
+ + text[-tail_n * 2:])
107
+
108
+ text = CLEAN_RE.sub(" ", text) # drop control/binary residue
109
+ text = SPACES_RE.sub(" ", text)
110
+
111
+ if len(text) > SKELETON_CHAR_BUDGET: # enforce the budget after cleanup
112
+ head_n = int(SKELETON_CHAR_BUDGET * HEAD_SHARE)
113
+ text = text[:head_n] + text[-(SKELETON_CHAR_BUDGET - head_n):]
114
+ truncated = True
115
+ return text, truncated, dropped
116
+
117
+
118
+ def mask_leaks(skeleton: str) -> str:
119
+ """Blank the brand identifiers, keep the structural shape of the injection intact."""
120
+ return LEAK_RE.sub(lambda m: "X" * len(m.group(0)), skeleton)
121
+
122
+
123
+ def scan_window(data: bytes, window: int = SCAN_WINDOW) -> bytes:
124
+ """Head and tail of a file, for the regex marker scans."""
125
+ if len(data) <= 2 * window:
126
+ return data
127
+ return data[:window] + data[-window:]
128
+
129
+
130
+ def detect_markers(blob) -> list:
131
+ """Which injection types are structurally present. Works on raw bytes or on a skeleton."""
132
+ data = blob.encode("latin-1", errors="replace") if isinstance(blob, str) else blob
133
+ data = scan_window(data)
134
+ return [name for name, patterns in INJECTION_MARKERS.items()
135
+ if any(re.search(p, data, re.IGNORECASE) for p in patterns)]
136
+
137
+
138
+ # One alternation instead of 26 separate scans - EDA cell 107. The earliest match of the union is
139
+ # by definition the earliest match of any individual pattern.
140
+ ANY_MARKER_RE = re.compile(
141
+ b"|".join([p for ps in INJECTION_MARKERS.values() for p in ps]
142
+ + [re.escape(s).encode() for s in FRAMEWORK_MARKERS + BASE_PAYLOAD_MARKERS]),
143
+ re.IGNORECASE)
144
+
145
+ WINDOW = 1_500 # characters kept either side of the payload - EDA cell 107
146
+
147
+
148
+ def payload_window(text: str, half: int = WINDOW) -> str:
149
+ """
150
+ The neighbourhood of the injection, exactly as the corpus column of this name was built.
151
+
152
+ Clean files have no marker, so they fall back to the head of the document - which keeps them
153
+ comparable in length rather than empty. Kept here unchanged because it is the single-window
154
+ case, and because `candidate_windows` below must agree with it character for character.
155
+ """
156
+ m = ANY_MARKER_RE.search(text.encode("latin-1", errors="replace"))
157
+ if not m:
158
+ return text[:2 * half]
159
+ return text[max(0, m.start() - half): m.start() + half]
160
+
161
+
162
+ # ---------------------------------------------------------------------------------------------
163
+ # Triage - new here, and the one place this Space departs from the notebooks
164
+ # ---------------------------------------------------------------------------------------------
165
+ #
166
+ # The notebooks scored one window per document, because they already knew where the payload was.
167
+ # An uploaded file offers no such promise: a payload can sit anywhere, and on the free CPU tier
168
+ # MiMo reads roughly one window every couple of minutes, so "score every window" is not on offer.
169
+ #
170
+ # So the same marker alternation that located the corpus payload is run over the *whole* skeleton
171
+ # instead of stopping at the first hit. Every match becomes a candidate window with the identical
172
+ # +/-1,500-character shape, they are merged where they overlap, and the most marker-dense ones go
173
+ # to the model first. A file with no marker anywhere yields exactly one candidate - the head of
174
+ # the document - which is byte-identical to what `payload_window` returns for a clean corpus file.
175
+ #
176
+ # What this is NOT: a detector. The ranking decides reading order, never the verdict. It also only
177
+ # knows the twelve families' signatures, so a payload shaped like none of them is triaged as if it
178
+ # were clean and the model sees the head of the file. That limit is stated in the UI, not buried.
179
+
180
+ MAX_MATCH_SCAN = 4_000 # matches considered; a pathological file will not run forever
181
+
182
+
183
+ def candidate_windows(skeleton: str, half: int = WINDOW) -> list:
184
+ """
185
+ Every +/-`half` neighbourhood around a marker in `skeleton`, merged and ranked.
186
+
187
+ Returns dicts with `start`, `end`, `text`, `n_markers`, `families` and `is_head`, most
188
+ marker-dense first. Never empty: with no markers at all it returns the head window.
189
+ """
190
+ blob = skeleton.encode("latin-1", errors="replace")
191
+
192
+ spans = []
193
+ for i, m in enumerate(ANY_MARKER_RE.finditer(blob)):
194
+ if i >= MAX_MATCH_SCAN:
195
+ break
196
+ spans.append((max(0, m.start() - half), m.start() + half, m.start()))
197
+
198
+ if not spans:
199
+ head = skeleton[:2 * half]
200
+ return [{"start": 0, "end": len(head), "text": head, "n_markers": 0,
201
+ "families": [], "is_head": True}]
202
+
203
+ # Merge overlaps so two markers 200 characters apart are read once, not twice.
204
+ merged = []
205
+ for start, end, hit in spans:
206
+ if merged and start <= merged[-1]["end"]:
207
+ merged[-1]["end"] = max(merged[-1]["end"], end)
208
+ merged[-1]["hits"].append(hit)
209
+ else:
210
+ merged.append({"start": start, "end": end, "hits": [hit]})
211
+
212
+ out = []
213
+ for span in merged:
214
+ # A merged span can grow past one window. The model reads at most 2*half characters, so
215
+ # centre the slice on the first marker in the span rather than sending a longer string
216
+ # than any corpus row ever carried.
217
+ first = span["hits"][0]
218
+ start = max(0, first - half)
219
+ text = skeleton[start:start + 2 * half]
220
+ out.append({"start": start, "end": start + len(text), "text": text,
221
+ "n_markers": len(span["hits"]),
222
+ "families": detect_markers(text), "is_head": False})
223
+
224
+ # Density first, then position: an early hit is where the head-insertion strategy puts things.
225
+ out.sort(key=lambda w: (-len(w["families"]), -w["n_markers"], w["start"]))
226
+ return out
mimo.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage 2 - asking MiMo.
3
+
4
+ The prompt, the prefill and the parser are **lifted verbatim from Part B**
5
+ (`Evaluation_of_OpenSource_Models_for_PDF_Injection_Recognition/Final_Project_Evaluation_V1.ipynb`,
6
+ cell 48). Same system message, same closed family list in the same order, same MiMo prefill, same
7
+ brace-counting parser, same greedy decoding, same 200-token cap. A reworded prompt or a widened
8
+ parser is a different experiment, and Part B's measured F1 of 0.945 would no longer describe it.
9
+
10
+ The one thing that is genuinely different from Part B is **how the weights are executed**. Part B
11
+ ran the BF16 checkpoint quantised to 4-bit NF4 through `bitsandbytes` on a T4. A free Hugging Face
12
+ Space has no GPU and `bitsandbytes` requires CUDA, so this runs the same weights as a Q4_K_M GGUF
13
+ through `llama.cpp` on the CPU. Both are 4-bit quantisations of `XiaomiMiMo/MiMo-7B-RL`, and they
14
+ are not the same 4-bit quantisation - see MODEL_CAVEAT, which the UI prints rather than hides.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import threading
21
+
22
+ FAMILIES = ["cross_site_scripting", "dde_template_injection", "javascript_injection",
23
+ "llm_prompt_injection", "object_action_injection", "polyglot_file",
24
+ "ransomware_simulation", "shellcode_embedded_exe", "ssrf",
25
+ "steganographic_payload", "uri_redirect_phishing", "xfa_acroform_injection"]
26
+
27
+ # ---------------------------------------------------------------------------------------------
28
+ # Verbatim from Part B, cell 48
29
+ # ---------------------------------------------------------------------------------------------
30
+
31
+ SYSTEM = (
32
+ "You are a PDF security analyst. You are given the raw extracted text of a PDF file - object "
33
+ "definitions, stream contents and metadata, exactly as they appear in the file. Some of these "
34
+ "files have had a malicious payload injected into them; most, but not all, have. Your job is to "
35
+ "say which, and to point at your evidence.\n\n"
36
+ "Answer with a single JSON object and nothing else:\n"
37
+ '{"injected": true or false, '
38
+ '"injection_type": one of ' + json.dumps(FAMILIES) + ' or "none", '
39
+ '"evidence": the exact substring from the input that convinced you, at most 200 characters, '
40
+ 'or "" if none, '
41
+ '"reasoning": one short sentence}\n\n'
42
+ "If the file looks clean, answer injected=false and injection_type=\"none\". Do not guess a "
43
+ "family when you do not believe there is an injection."
44
+ )
45
+
46
+ MAX_CHARS = 3000 # payload_window is already capped at this; belt and braces
47
+ MAX_NEW = 200
48
+
49
+
50
+ def build_messages(text: str):
51
+ """The chat turns for one window."""
52
+ return [{"role": "system", "content": SYSTEM},
53
+ {"role": "user", "content": "PDF extract:\n\n```\n" + text[:MAX_CHARS] + "\n```"}]
54
+
55
+
56
+ # Text appended to the assistant turn, so the model resumes from it instead of starting free.
57
+ # MiMo is reasoning-trained, opens every answer with `<think>`, and at MAX_NEW = 200 the budget is
58
+ # gone before the block closes - not one of its 1,100 Part B answers contained a closing
59
+ # `</think>`, so it never reached the JSON. An empty, already-closed block says the deliberation is
60
+ # finished before it begins, and the opening brace puts it inside the answer.
61
+ PREFILL = '<think>\n\n</think>\n\n{"injected":'
62
+
63
+
64
+ def scan_objects(raw: str):
65
+ r"""
66
+ Every balanced {...} in the text, counting braces and skipping anything inside a string literal.
67
+
68
+ A regex cannot do this - matching balanced delimiters is outside what regular expressions can
69
+ express - and the naive `\{[^{}]*\}` this replaced was actively harmful: every injected file in
70
+ this corpus carries an EICAR-style marker containing a `}`, so the moment a model quoted its
71
+ evidence the match was truncated mid-string and the verdict was discarded. The bug fired
72
+ exactly when the model was RIGHT.
73
+ """
74
+ objs, depth, start, in_str, esc = [], 0, None, False, False
75
+ for i, ch in enumerate(raw or ""):
76
+ if in_str:
77
+ if esc:
78
+ esc = False
79
+ elif ch == "\\":
80
+ esc = True
81
+ elif ch == '"':
82
+ in_str = False
83
+ continue
84
+ if ch == '"':
85
+ in_str = True
86
+ elif ch == "{":
87
+ if depth == 0:
88
+ start = i
89
+ depth += 1
90
+ elif ch == "}":
91
+ depth -= 1
92
+ if depth == 0 and start is not None:
93
+ objs.append(raw[start:i + 1])
94
+ start = None
95
+ depth = max(depth, 0)
96
+ return objs
97
+
98
+
99
+ def parse_response(raw: str) -> dict:
100
+ """
101
+ Pull the verdict out of whatever the model said.
102
+
103
+ The LAST balanced object is taken, not the first: reasoning-trained models restate the schema
104
+ while thinking and emit the real answer at the end. If nothing parses as JSON the two fields
105
+ that matter are lifted out individually rather than thrown away. Only a response with no
106
+ recoverable verdict counts as parse_ok=False, and that reads as 'not injected': a detector that
107
+ cannot make itself understood has caught nothing.
108
+
109
+ Kept deliberately narrow, exactly as Part B scored it. A wider salvage would recover more
110
+ verdicts and would also mean the F1 quoted in the UI describes a parser that is not this one.
111
+ """
112
+ for m in reversed(scan_objects(raw)):
113
+ try:
114
+ obj = json.loads(m)
115
+ except json.JSONDecodeError:
116
+ continue
117
+ if "injected" in obj:
118
+ inj = obj["injected"]
119
+ inj = inj if isinstance(inj, bool) else str(inj).strip().lower() in {"true", "yes", "1"}
120
+ fam = str(obj.get("injection_type", "none") or "none").strip().lower()
121
+ return {"parse_ok": True, "parsed_by": "balanced JSON",
122
+ "pred_injected": int(inj),
123
+ "pred_family": fam if fam in FAMILIES else "none",
124
+ "evidence": str(obj.get("evidence", ""))[:200],
125
+ "reasoning": str(obj.get("reasoning", ""))[:300]}
126
+
127
+ m = re.search(r'"injected"\s*:\s*(true|false)', raw or "", re.I)
128
+ if m:
129
+ f = re.search(r'"injection_type"\s*:\s*"([a-z_]+)"', raw, re.I)
130
+ fam = f.group(1).lower() if f else "none"
131
+ return {"parse_ok": True, "parsed_by": "field regex",
132
+ "pred_injected": int(m.group(1).lower() == "true"),
133
+ "pred_family": fam if fam in FAMILIES else "none",
134
+ "evidence": "", "reasoning": ""}
135
+
136
+ return {"parse_ok": False, "parsed_by": "unrecoverable", "pred_injected": 0,
137
+ "pred_family": "none", "evidence": "", "reasoning": ""}
138
+
139
+
140
+ # ---------------------------------------------------------------------------------------------
141
+ # Execution - CPU, llama.cpp, Q4_K_M
142
+ # ---------------------------------------------------------------------------------------------
143
+
144
+ BASE_REPO = "XiaomiMiMo/MiMo-7B-RL"
145
+
146
+ # MiMo carries Multi-Token-Prediction layers that llama.cpp cannot load, so the only GGUF that runs
147
+ # at all is one with those layers removed. MTP is a speculative-decoding accelerator - the ordinary
148
+ # forward pass does not use it - so greedy output should be unaffected, but "should be" is doing
149
+ # real work in that sentence and the quantiser says so too.
150
+ GGUF_REPO = "quantflex/MiMo-7B-RL-nomtp-GGUF"
151
+ GGUF_FILE = "MiMo-7B-RL-nomtp-Q4_K_M.gguf"
152
+
153
+ MODEL_CAVEAT = (
154
+ "Part B measured MiMo-7B-RL on a GPU as BF16 weights quantised to 4-bit NF4 by bitsandbytes. "
155
+ "A free Space has no GPU, so this runs the same base model as a Q4_K_M GGUF through llama.cpp "
156
+ "on the CPU, from a build with MiMo's multi-token-prediction layers stripped (llama.cpp cannot "
157
+ "load them). The prompt, prefill, decoding and parser are byte-identical to Part B; the "
158
+ "arithmetic underneath is not. Treat F1 0.945 as the figure for the configuration Part B "
159
+ "measured, not as a measurement of this Space."
160
+ )
161
+
162
+ N_CTX = 4096 # a 3,000-char window is ~1,000 tokens, plus 200 generated
163
+
164
+ _llm = None
165
+ _tokenizer = None
166
+ _lock = threading.Lock() # one 4.7 GB model, one request at a time
167
+
168
+
169
+ def model_path(progress=None) -> str:
170
+ """Fetch the GGUF from the hub, cached on the Space's disk after the first run."""
171
+ from huggingface_hub import hf_hub_download
172
+ if progress:
173
+ progress(f"downloading {GGUF_FILE} (4.7 GB, first run only)")
174
+ return hf_hub_download(GGUF_REPO, GGUF_FILE)
175
+
176
+
177
+ def load_tokenizer():
178
+ """
179
+ MiMo's own tokenizer, used **only** to apply its chat template.
180
+
181
+ The template is the wrapper of role tags around the prompt, and Part B let each model's own
182
+ tokenizer write it. Doing the same here rather than hand-rolling ChatML is what keeps the
183
+ rendered string identical to the one that was measured. Only the tokenizer files are
184
+ downloaded - a few megabytes, not the weights.
185
+ """
186
+ global _tokenizer
187
+ if _tokenizer is None:
188
+ from transformers import AutoTokenizer
189
+ _tokenizer = AutoTokenizer.from_pretrained(BASE_REPO, trust_remote_code=True)
190
+ return _tokenizer
191
+
192
+
193
+ def render_prompt(text: str) -> str:
194
+ """
195
+ Apply MiMo's chat template to one window, then append the prefill.
196
+
197
+ If the tokenizer cannot be reached the ChatML fallback is used. MiMo is a ChatML model, so this
198
+ produces the same string in practice - but it is a reconstruction rather than the model's own
199
+ template, so the caller is told which route was taken instead of the difference being silent.
200
+ """
201
+ messages = build_messages(text)
202
+ try:
203
+ rendered = load_tokenizer().apply_chat_template(
204
+ messages, tokenize=False, add_generation_prompt=True)
205
+ route = "tokenizer template"
206
+ except Exception:
207
+ rendered = "".join(f"<|im_start|>{m['role']}\n{m['content']}<|im_end|>\n"
208
+ for m in messages) + "<|im_start|>assistant\n"
209
+ route = "ChatML fallback"
210
+ return rendered + PREFILL, route
211
+
212
+
213
+ def load(progress=None):
214
+ """Load MiMo once and keep it. ~4.7 GB resident, well inside the free tier's 16 GB."""
215
+ global _llm
216
+ if _llm is None:
217
+ from llama_cpp import Llama
218
+ path = model_path(progress)
219
+ if progress:
220
+ progress("loading MiMo-7B into memory")
221
+ _llm = Llama(model_path=path, n_ctx=N_CTX, n_threads=os.cpu_count() or 2,
222
+ n_batch=256, logits_all=False, verbose=False)
223
+ return _llm
224
+
225
+
226
+ def judge(text: str, progress=None) -> dict:
227
+ """
228
+ One window in, one parsed verdict out.
229
+
230
+ Greedy (`temperature=0.0`), 200 new tokens, exactly as Part B decoded. The prefill was given to
231
+ the model but not generated by it, so it is put back before parsing or the opening brace of the
232
+ JSON is missing and every answer parses as unrecoverable.
233
+ """
234
+ llm = load(progress)
235
+ prompt, route = render_prompt(text)
236
+ with _lock:
237
+ out = llm.create_completion(prompt=prompt, max_tokens=MAX_NEW, temperature=0.0,
238
+ top_k=1, stop=["<|im_end|>", "<|endoftext|>"])
239
+ raw = PREFILL + out["choices"][0]["text"]
240
+ return {**parse_response(raw), "raw": raw.strip()[:2000], "prompt_route": route}
neighbours.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Stage 3 - where this window sits in the corpus Part A measured.
3
+
4
+ Part A's bake-off picked `nomic-ai/nomic-embed-text-v1.5` over MiniLM and BGE, embedding the
5
+ `payload_window` column with the `search_document: ` prefix its model card specifies for indexing,
6
+ unit-normalised, 768 dimensions. Those four facts are recorded in `part_a_results.json` under
7
+ `winner`, and `check_provenance()` asserts them against that file rather than trusting this
8
+ docstring - because a query embedded with a different model, or without the prefix, lands in a
9
+ different space and every neighbour returned is meaningless, silently, with no error anywhere.
10
+
11
+ What it is worth: **precision@5 of 35.6%** against a 6.8% random baseline. Fewer than 2 of the 5
12
+ files returned are the same kind of attack as the query. That is far better than chance and it is
13
+ not good, which is why this is labelled "nearest files in the corpus" and never "the same attack".
14
+ """
15
+
16
+ import json
17
+ import re
18
+ import urllib.request
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ REPO = "Cyber-security-final-project/Evaluation_of_OpenSource_Models_for_PDF_Injection_Recognition"
24
+ BASE = f"https://huggingface.co/datasets/{REPO}/resolve/main/"
25
+ INDEX_URL = BASE + "Part_A_Outputs/corpus_embeddings.parquet"
26
+ RESULTS_URL = BASE + "Part_A_Outputs/part_a_results.json"
27
+
28
+ # Part A's winner. Every field here is asserted against the results file before use.
29
+ EMBED_REPO = "nomic-ai/nomic-embed-text-v1.5"
30
+ PREFIX = "search_document: "
31
+ DIMS = 768
32
+
33
+ # The corpus filename encodes its own attack family ("javascript_injection_OWASP_0004.pdf"), and
34
+ # the clean controls are hash-named. That is a label on a known corpus file, not a model input -
35
+ # nothing here ever shows a filename to a model.
36
+ FAMILY_RE = re.compile(r"^([a-z_]+?)_(?:AMTSO|WICAR|OWASP|AtomicRedTeam|Metasploit|Glasswall|"
37
+ r"mindcrypt|RanSim|RANSIM|custom)_\d+\.pdf$")
38
+
39
+ _index = None
40
+ _model = None
41
+
42
+
43
+ def family_of(file_id: str) -> str:
44
+ """The attack family a corpus file carries, read off its name. Clean files are hash-named."""
45
+ m = FAMILY_RE.match(file_id or "")
46
+ return m.group(1) if m else "clean (control)"
47
+
48
+
49
+ def check_provenance() -> dict:
50
+ """Assert this module embeds queries the way the index was built. Cheap, and load-bearing."""
51
+ with urllib.request.urlopen(RESULTS_URL) as r:
52
+ winner = json.loads(r.read().decode())["winner"]
53
+ assert winner["repo"] == EMBED_REPO, f"index built by {winner['repo']}, app uses {EMBED_REPO}"
54
+ assert winner["prefix"] == PREFIX, "prefix differs from the one the index was built with"
55
+ assert winner["dims"] == DIMS, "dimension mismatch"
56
+ assert winner["normalised"], "index is not unit-normalised; the dot product is not cosine"
57
+ assert winner["input_column"] == "payload_window", "index was not built on payload windows"
58
+ return winner
59
+
60
+
61
+ def load_index():
62
+ """The 1,100 x 768 index Part A exported, straight from the dataset repo. ~3 MB."""
63
+ global _index
64
+ if _index is None:
65
+ df = pd.read_parquet(INDEX_URL)
66
+ _index = {"ids": df["file_id"].to_numpy(),
67
+ "matrix": df.drop(columns="file_id").to_numpy(dtype=np.float32)}
68
+ return _index
69
+
70
+
71
+ def load_model():
72
+ """Nomic-embed-v1.5 on the CPU. ~550 MB, and a 3,000-character window embeds in a second."""
73
+ global _model
74
+ if _model is None:
75
+ from sentence_transformers import SentenceTransformer
76
+ _model = SentenceTransformer(EMBED_REPO, trust_remote_code=True, device="cpu")
77
+ return _model
78
+
79
+
80
+ def embed(text: str) -> np.ndarray:
81
+ """Embed with the prefix and normalisation Part A used. Anything else is a different space."""
82
+ return load_model().encode([PREFIX + text], normalize_embeddings=True,
83
+ show_progress_bar=False)[0]
84
+
85
+
86
+ def neighbours(text: str, k: int = 5) -> list:
87
+ """
88
+ The k corpus files nearest one window.
89
+
90
+ Vectors are unit-normalised, so the dot product is the cosine similarity and the whole lookup
91
+ against 1,100 files is one matrix-vector product - no index structure needed at this size.
92
+ """
93
+ idx = load_index()
94
+ sims = idx["matrix"] @ embed(text)
95
+ top = np.argsort(-sims)[:k]
96
+ return [{"file_id": str(idx["ids"][i]),
97
+ "family": family_of(str(idx["ids"][i])),
98
+ "similarity": round(float(sims[i]), 3)} for i in top]
99
+
100
+
101
+ NEIGHBOUR_COLUMNS = ["corpus file", "its attack family", "cosine similarity"]
102
+
103
+
104
+ def neighbour_rows(text: str, k: int = 5) -> list:
105
+ return [[n["file_id"], n["family"], n["similarity"]] for n in neighbours(text, k)]
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CPU-only wheels, both of them deliberate.
2
+ #
3
+ # torch: sentence-transformers pulls torch, and the default PyPI wheel is the CUDA build - about
4
+ # 2.5 GB of NVIDIA libraries that a CPU Space can never use, and enough to make the image build
5
+ # fail on size alone. The cpu index gives a ~200 MB wheel instead.
6
+ #
7
+ # llama-cpp-python: the sdist compiles llama.cpp from source, which on a Space build takes tens of
8
+ # minutes and often times out. abetlen's index publishes prebuilt CPU wheels; 0.3.19 is the newest
9
+ # one there, and is new enough to load a Qwen2-architecture GGUF like MiMo.
10
+ --extra-index-url https://download.pytorch.org/whl/cpu
11
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
12
+
13
+ torch
14
+ llama-cpp-python==0.3.19
15
+
16
+ gradio>=4.44
17
+ huggingface_hub>=0.23
18
+ transformers>=4.45
19
+ sentence-transformers>=3.0
20
+ einops # nomic-embed-v1.5's remote code imports it
21
+ numpy
22
+ pandas
23
+ pyarrow # read the Part A embedding index
test_fidelity.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Does this Space read a PDF the way the corpus was read?
3
+
4
+ This is the test the whole design rests on. Part A's index and Part B's F1 of 0.945 were measured
5
+ on `skeleton_masked` and `payload_window` strings produced by the EDA notebook's extractor. If
6
+ `corpus_text.py` produces even slightly different text, those numbers stop describing this app and
7
+ become decoration.
8
+
9
+ So it pulls real PDFs out of the generation repo, runs them through `corpus_text.py`, and compares
10
+ the result **character by character** against the published parquet. Not "close enough" — identical.
11
+
12
+ python test_fidelity.py [n_files]
13
+ """
14
+
15
+ import sys
16
+
17
+ import pandas as pd
18
+
19
+ import corpus_text
20
+
21
+ CORPUS = ("https://huggingface.co/datasets/Cyber-security-final-project/"
22
+ "HARMLESS_Synthetic_Injected_PDFs_EDA/resolve/main/Datasets/"
23
+ "synthetic_corpus_part2_clustered.parquet")
24
+ GENERATION_REPO = "Cyber-security-final-project/Generated_Injected_PDFs_HARMLESS"
25
+
26
+
27
+ def main(n=8):
28
+ from huggingface_hub import hf_hub_download
29
+
30
+ corpus = pd.read_parquet(CORPUS).set_index("file_id")
31
+
32
+ # Injected and clean both, and not all from one family: masking and binary-stream handling
33
+ # differ between them, and a test that only saw one would pass on a broken extractor.
34
+ ids = list(corpus.index)
35
+ sample = ids[::max(1, len(ids) // n)][:n]
36
+
37
+ failures = 0
38
+ for fid in sample:
39
+ row = corpus.loc[fid]
40
+ try:
41
+ path = hf_hub_download(GENERATION_REPO, f"Output_PDFs/{fid}", repo_type="dataset")
42
+ except Exception as e:
43
+ print(f" ? {fid}: could not fetch ({type(e).__name__})")
44
+ continue
45
+
46
+ skeleton, _, _ = corpus_text.build_skeleton(open(path, "rb").read())
47
+ masked = corpus_text.mask_leaks(skeleton)
48
+ window = corpus_text.payload_window(skeleton)
49
+
50
+ checks = {"skeleton_masked": (masked, row["skeleton_masked"]),
51
+ "payload_window": (window, row["payload_window"])}
52
+
53
+ # The single-window path must also be what the triage returns first for a marked file,
54
+ # or the model reads a different string here than the corpus was scored on.
55
+ top = corpus_text.candidate_windows(skeleton)[0]
56
+
57
+ bad = [k for k, (got, want) in checks.items() if got != want]
58
+ if bad:
59
+ failures += 1
60
+ print(f" x {fid}: differs in {', '.join(bad)}")
61
+ for k in bad:
62
+ got, want = checks[k]
63
+ print(f" {k}: got {len(got):,} chars, corpus has {len(want):,}")
64
+ else:
65
+ note = "head" if top["is_head"] else f"{len(top['families'])} family marker(s)"
66
+ print(f" . {fid}: identical (triage top = {note})")
67
+
68
+ print(f"\n{len(sample) - failures}/{len(sample)} identical to the published corpus")
69
+ return 1 if failures else 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ sys.exit(main(int(sys.argv[1]) if len(sys.argv) > 1 else 8))