Spaces:
Running on Zero
Running on Zero
MiMo-7B PDF injection detector: CPU/GGUF runtime, verbatim Part A+B logic
Browse files- README.md +101 -6
- app.py +225 -0
- corpus_text.py +226 -0
- mimo.py +240 -0
- neighbours.py +105 -0
- requirements.txt +23 -0
- test_fidelity.py +73 -0
README.md
CHANGED
|
@@ -1,13 +1,108 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: yellow
|
| 5 |
-
colorTo:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|