File size: 31,369 Bytes
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
f5c6cec
d113a7f
 
 
 
 
 
 
f16b077
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
f5c6cec
 
d80154b
 
 
 
f5c6cec
d80154b
 
 
f5c6cec
d80154b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5c6cec
d113a7f
346a1ad
 
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346a1ad
d113a7f
 
 
 
 
 
 
 
 
 
 
0a12b17
 
 
 
d113a7f
346a1ad
d113a7f
 
346a1ad
d113a7f
 
 
 
 
 
 
 
 
 
 
 
0a12b17
 
 
 
 
 
 
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346a1ad
d113a7f
 
 
0a12b17
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346a1ad
 
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
346a1ad
 
d113a7f
346a1ad
d113a7f
 
346a1ad
 
 
0e69d49
 
 
 
 
346a1ad
 
d113a7f
 
 
 
 
 
 
 
 
 
 
 
346a1ad
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5138b3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346a1ad
d113a7f
 
 
 
 
 
 
 
 
 
 
 
44115c8
 
 
d113a7f
 
 
f16b077
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346a1ad
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
f16b077
 
 
 
 
 
 
d113a7f
 
 
 
0e69d49
 
 
 
 
 
d113a7f
 
 
 
 
 
 
 
 
 
 
d80154b
 
 
 
 
 
f5c6cec
d80154b
 
 
 
 
 
 
 
346a1ad
 
 
 
 
0e69d49
 
 
 
 
d113a7f
 
0a12b17
 
 
 
 
 
d113a7f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d80154b
d113a7f
 
 
 
 
 
 
 
 
 
 
 
d80154b
 
 
 
 
 
346a1ad
 
 
5138b3d
 
 
 
 
 
 
 
 
d113a7f
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
"""

PDF Injection Detector - MiMo-7B.



Upload a PDF, and this reads it the way the corpus was read, cuts it into batches of regions that

fit one run of the model, and asks MiMo-7B-RL whether a payload is hidden in the batch you choose.



`app.py` holds the interface, the batching and the aggregation. It contains no detection logic of

its own: the text extraction lives in `corpus_text.py`, the prompt and parser in `mimo.py`, the

embedding lookup in `neighbours.py`, and each is quoted from the notebook that measured it.

"""

import re
import traceback
from pathlib import Path

import gradio as gr

import corpus_text
import mimo
import neighbours

# What to do about each family, shown under a payload verdict.
#
# One entry per family in `mimo.FAMILIES`, and the keys are asserted against that list at import so
# a renamed family cannot silently lose its advice. Each is written against the generator's own
# object shapes rather than the general security literature, so a reader who opens a flagged file
# finds the thing the text describes.
#
# **This is advice conditional on a guess.** MiMo names the family correctly 43% of the time, so
# more often than not the paragraph shown here is the remedy for a different attack than the one
# present. It is worth showing anyway - the first line of every entry is a containment step that
# holds whatever the family turns out to be - but the interface says so rather than implying the
# app knows what it is looking at.
TREATMENT = {
    "javascript_injection": (
        "Quarantine the file immediately. Do not open in Adobe Reader or any JS-enabled viewer. "
        "Strip the /JS and /JavaScript PDF objects using a PDF sanitiser (e.g. qpdf or mutool). "
        "Disable JavaScript in PDF readers organisation-wide via Group Policy."),
    "cross_site_scripting": (
        "Do not open in a browser-based PDF viewer. Use a sandboxed desktop reader only. "
        "Submit to your security team for content stripping before redistribution."),
    "ssrf": (
        "Block outbound HTTP requests from any server that processes this file. "
        "Do not open on cloud infrastructure without egress filtering - the payload targets "
        "the AWS/GCP metadata endpoint (169.254.169.254). Flag for security review."),
    "object_action_injection": (
        "Open only in read-only sandboxed mode. Strip /Launch, /OpenAction and /AA objects "
        "using a PDF sanitiser. Alert your SOC team - this payload attempts to execute a "
        "program outside the PDF viewer, with the opening user's own permissions."),
    "llm_prompt_injection": (
        "Do not feed this document to any AI/LLM pipeline without human review first. "
        "The payload attempts to override AI instructions. Sanitise or reject the file "
        "before any automated processing."),
    "shellcode_embedded_exe": (
        "Quarantine immediately and isolate the machine. Run through antivirus before any "
        "attachment is extracted, and do not let anyone double-click what comes out. "
        "Report the binary stream hash to your threat intelligence team."),
    "dde_template_injection": (
        "Do not copy any field contents into Excel or Word - the payload activates as a DDE "
        "formula once it reaches Office, not while the PDF is open. Strip /AcroForm field "
        "values and any remote template reference, and block outbound requests to the linked "
        "template host."),
    "polyglot_file": (
        "Treat this as two files, not one. Scan it again as an archive as well as a PDF: "
        "whichever format your scanner did not choose was never inspected at all. Do not let "
        "it through a filter that decides file type from the extension or the leading bytes "
        "alone."),
    "ransomware_simulation": (
        "Isolate the host before anything else, and do not open the file. Confirm backups are "
        "offline and restorable, then hand the sample to incident response. In this corpus the "
        "payload is a harmless RANSIM/EICAR simulation - an unknown file of this shape should "
        "be treated as the real thing until proven otherwise."),
    "steganographic_payload": (
        "Do not forward the file. Extract and inspect the embedded image streams separately: "
        "the payload hides in pixel data and survives filters that look for code or links. "
        "Re-encode or drop the images before any redistribution."),
    "uri_redirect_phishing": (
        "Do not click anywhere in the document - the link annotation covers the entire page, "
        "so any click opens it. Verify the destination host out of band, strip /URI actions "
        "with a PDF sanitiser, and report the URL to your phishing intake."),
    "xfa_acroform_injection": (
        "Strip the XFA form definition before the file is processed further. The payload lives "
        "in an XML document inside the PDF, so scanners that parse only PDF objects never see "
        "it. Do not open in a reader with XFA support enabled."),
}

# A verdict can be `injected=true` with `injection_type="none"` - the model is sure something is
# wrong and will not say what. That still deserves an answer.
TREATMENT_UNNAMED = (
    "MiMo flagged this region but would not name a family, so no specific remediation applies. "
    "Treat the file as untrusted: do not open it on a machine that matters, do not forward it, "
    "and pass it to whoever handles security for you.")

assert set(TREATMENT) == set(mimo.FAMILIES), (
    f"TREATMENT does not cover mimo.FAMILIES: "
    f"missing {sorted(set(mimo.FAMILIES) - set(TREATMENT))}, "
    f"unknown {sorted(set(TREATMENT) - set(mimo.FAMILIES))}")

# Measured in Part B on a T4, on the project's own 1,100-document corpus.
PART_B = {"f1": 0.945, "precision": 0.988, "recall": 0.906, "family_acc": 0.433,
          "false_alarm_rate": 0.05, "unparsable": 155, "n": 1100}

GPU = mimo.BACKEND == "gpu"

# How many regions fit in ONE run is not a taste decision. On ZeroGPU a single grant is capped at
# 300 seconds and the whole scan plus the 4-bit load must fit inside it; on 2 vCPUs a region costs
# two minutes and a browser will not wait for many. That ceiling is what a batch is.
MAX_PER_BATCH = mimo.MAX_WINDOWS_GPU if GPU else 6
DEFAULT_PER_BATCH = 8 if GPU else 3

WINDOW_COLUMNS = ["#", "where in the skeleton", "kind", "markers", "verdict", "family", "evidence"]

# One example per injection family, plus one clean control, taken from the generation repo.
#
# The files on disk are named Example_N and nothing else, and that stays true: the uploader shows
# a neutral filename, and nothing about the file announces its own answer. The *buttons* are
# labelled by family so the demo can be driven deliberately ("show me ransomware"), which costs
# nothing on the model side - a filename never reaches the prompt, only extracted text does.
#
# Labels come from `examples/manifest.json`, written by the same script that copies the PDFs, so
# a button cannot end up pointing at the wrong family. `examples/Example_Key.txt` is the same
# mapping in prose, for whoever is marking this.
EXAMPLE_DIR = Path(__file__).resolve().parent / "examples"


def load_examples():
    """[(label, path)], families first in the model's own order, clean last. Empty if absent."""
    manifest = EXAMPLE_DIR / "manifest.json"
    if not manifest.exists():
        return []
    import json
    mapping = json.loads(manifest.read_text(encoding="utf-8"))
    order = {f: i for i, f in enumerate(mimo.FAMILIES)}
    rows = [(fam, EXAMPLE_DIR / name) for name, fam in mapping.items()
            if (EXAMPLE_DIR / name).exists()]
    # "clean" is not in FAMILIES, so it sorts last - which is where it belongs: it is the control,
    # read after you have seen what a hit looks like.
    return sorted(rows, key=lambda r: order.get(r[0], len(order)))


EXAMPLES = load_examples()


def fmt_eta(n: int, runtime: str = None) -> str:
    seconds = n * mimo.SECONDS[runtime or mimo.BACKEND]
    if seconds < 90:
        return f"about {max(20, int(seconds))}s"
    return f"{seconds * 0.6 / 60:.0f}-{seconds * 1.5 / 60:.0f} min"


def extract(path, cover_all=True):
    """PDF bytes to ranked candidate regions. No model touched, so this runs on upload."""
    with open(path, "rb") as fh:
        data = fh.read()
    skeleton, truncated, dropped = corpus_text.build_skeleton(data)
    return (data, skeleton, truncated, dropped,
            corpus_text.candidate_windows(skeleton, cover_all=bool(cover_all)))


def batches_of(candidates, per_batch):
    """The ranked regions cut into runnable chunks. Batch 1 is the most marker-dense."""
    per_batch = max(1, int(per_batch))
    return [candidates[i:i + per_batch] for i in range(0, len(candidates), per_batch)]


def batch_label(batch, i, n_batches, runtime=None):
    """What one batch is, in a line, so the choice is informed rather than a number."""
    first, last = batch[0], batch[-1]
    if first["is_head"]:
        where = "head of document"
    else:
        where = f"chars {min(w['start'] for w in batch):,}-{max(w['end'] for w in batch):,}"
    fams = sorted({f for w in batch for f in w["families"]})
    n_marker = sum(1 for w in batch if w["source"] == "marker")
    kind = ("marker regions" if n_marker == len(batch) else
            "sweep of the document" if n_marker == 0 else
            f"{n_marker} marker + {len(batch) - n_marker} sweep")
    # "signature:" and not the bare family name. The regex has only seen a token like `/JS (` in
    # the text; plenty of harmless PDFs contain one. Printing "javascript_injection" on its own
    # before the model has read anything reads as a verdict the app has not made.
    tail = f" · signature: {', '.join(fams)}" if fams else ""
    return (f"Batch {i + 1} of {n_batches}{len(batch)} region(s), {kind} · {where}{tail} · "
            f"~{fmt_eta(len(batch), runtime)}")


def on_upload(path, per_batch, cover_all, runtime):
    """Extract, rank and batch. Fast enough to run on every upload and every slider move."""
    if not path:
        return (None, "Upload a PDF to see what will be read.",
                gr.update(choices=[], value=None, interactive=False))

    try:
        data, skeleton, truncated, dropped, candidates = extract(path, cover_all)
    except Exception as e:
        return (None, f"Could not read that file: `{type(e).__name__}: {e}`",
                gr.update(choices=[], value=None, interactive=False))

    groups = batches_of(candidates, per_batch)

    # Plain label strings, NOT (label, index) pairs. The dropdown allows custom values so the API
    # can name a batch before any PDF has been uploaded, and that turns it into a free-text
    # combobox: a programmatic integer value of 0 is falsy, so the displayed text refused to
    # refresh and the box kept showing the previous split after the slider moved. Strings are
    # never falsy here, and `resolve_batch` reads the index straight back out of "Batch N of M".
    choices = [batch_label(b, i, len(groups), runtime) for i, b in enumerate(groups)]

    lines = [
        f"**{len(data):,} bytes** on disk, rendered to a **{len(skeleton):,}-character skeleton**"
        + (f" (truncated to the {corpus_text.SKELETON_CHAR_BUDGET:,}-character budget)"
           if truncated else "")
        + (f", {dropped} binary stream(s) dropped." if dropped else "."),
    ]
    n_marker = sum(1 for w in candidates if w["source"] == "marker")
    fams = corpus_text.detect_markers(data)

    if n_marker:
        lines.append(
            f"**{n_marker} region(s) carry a marker**, and the remaining "
            f"{len(candidates) - n_marker} cover the rest of the document — "
            f"**{len(candidates)} in total, cut into {len(groups)} batch(es)** of at most "
            f"{int(per_batch)}. Structural signatures in the raw file: "
            f"`{'`, `'.join(fams) or 'none'}`.")
        lines.append(
            "_Ranking decides **reading order only** — batch 1 is the most marker-dense, not the "
            "guilty one. The verdict is MiMo's alone._")
    elif cover_all:
        lines.append(
            f"**No structural marker anywhere in the file.** The triage only recognises the twelve "
            f"families this project generated, so this means either a clean file or a payload "
            f"shaped like none of them — which is why the batches below sweep the **whole** "
            f"skeleton rather than stopping here: **{len(candidates)} region(s) in "
            f"{len(groups)} batch(es)**.")
    else:
        lines.append(
            "**No structural marker anywhere in the file**, and the sweep is switched off — so "
            "only the head of the document will be read, which is exactly what the corpus builder "
            "produced for a *clean* file. Switch the sweep on to look at the rest of it.")

    lines.append(f"Pick a batch and press **Check this batch**. One batch is one run of the model, "
                 f"sized to fit the **{(runtime or mimo.BACKEND).upper()}** runtime's limit; run as many "
                 f"batches as you like, one at a time.")

    return ((skeleton, candidates), "\n\n".join(lines),
            gr.update(choices=choices, value=choices[0], interactive=True))


def resolve_batch(value, n_batches: int) -> int:
    """

    Whatever the dropdown handed back, as a batch index that exists.



    Because the dropdown allows custom values it can arrive as the integer index, as `None` before

    anything was picked, or as the label string itself. All three mean something, and none of them

    should be an exception in front of a user who just pressed a button.

    """
    if isinstance(value, (int, float)):
        idx = int(value)
    else:
        digits = re.search(r"\d+", str(value or ""))
        idx = int(digits.group()) - 1 if digits else 0     # labels are 1-based, indices are not
    return idx if 0 <= idx < n_batches else 0


def run(path, per_batch, batch_index, want_neighbours, cover_all, runtime,

        progress=gr.Progress()):
    """Score one batch."""
    if not path:
        return "Upload a PDF first.", [], [], ""

    progress(0.05, desc="reading the PDF")
    try:
        _, _, _, _, candidates = extract(path, cover_all)
    except Exception as e:
        return f"Could not read that file: `{type(e).__name__}: {e}`", [], [], ""

    groups = batches_of(candidates, per_batch)
    idx = resolve_batch(batch_index, len(groups))
    batch = groups[idx]

    runtime = runtime or mimo.BACKEND
    progress(0.15, desc=f"loading MiMo ({runtime})")
    try:
        answers = mimo.judge_all([w["text"] for w in batch], backend=runtime,
                                 progress=lambda m: progress(0.4, desc=m))
    except Exception as e:
        hint = ""
        if "quota" in str(e).lower():
            hint = ("\n\nA free account gets about **five minutes of ZeroGPU per day**, and the "
                    "scheduler reserves a whole run up front. The quota is per visitor, so this "
                    "affects your account rather than the Space.")
            hint += ("\n\nSwitch the runtime to **cpu** — much slower, but no quota — or come back "
                     "tomorrow." if mimo.CPU_AVAILABLE else
                     " Fewer regions per batch costs less of it; otherwise it resets in 24 hours.")
        return (f"## MiMo could not run\n\n`{type(e).__name__}: {e}`{hint}\n\nRuntime selected: "
                f"**{runtime}**."), [], [], traceback.format_exc()

    offset = idx * max(1, int(per_batch))
    rows, log, results = [], [], list(zip(batch, answers))
    for i, (win, r) in enumerate(results):
        where = "head of document" if win["is_head"] else f"chars {win['start']:,}-{win['end']:,}"
        verdict = ("PAYLOAD" if r["pred_injected"] else "clean") + (
            "" if r["parse_ok"] else " (unreadable answer)")
        rows.append([offset + i + 1, where, win["source"], ", ".join(win["families"]) or "-",
                     verdict, r["pred_family"], (r["evidence"] or "-")[:160]])
        log.append(f"--- region {offset + i + 1} ({where}, prompt via {r['prompt_route']}) ---\n"
                   f"{r['raw']}")

    report = build_report(results, idx, groups, len(candidates), runtime)

    nb_rows = []
    if want_neighbours and results:
        progress(0.95, desc="embedding and looking up the corpus")
        flagged = next((w for w, r in results if r["pred_injected"]), None)
        query = flagged or batch[0]
        try:
            neighbours.check_provenance()
            nb_rows = neighbours.neighbour_rows(query["text"], k=5)
            basis = ("the first flagged region" if flagged else
                     "the first region in this batch (nothing was flagged)")
            report += (f"\n\n### Nearest files in the corpus\n\nEmbedded from **{basis}** with "
                       f"Part A's winning configuration. Precision@5 on this index is **35.6%** "
                       f"against a 6.8% random baseline: fewer than 2 of the 5 listed are the same "
                       f"kind of attack. Read it as *resemblance*, not identification.")
        except Exception as e:
            report += (f"\n\n### Nearest files in the corpus\n\nUnavailable: "
                       f"`{type(e).__name__}: {e}`")
            log.append(traceback.format_exc())

    return report, rows, nb_rows, "\n\n".join(log)


def begin(per_batch, runtime):
    """

    Lock the button and say so, before the slow part starts.



    A second press while MiMo is mid-scan is worse here than in most apps: on ZeroGPU it queues a

    second grant against a daily quota that only affords one or two, so the cost of a stray click

    is the rest of the day. The button is disabled for the duration and `mimo._lock` serialises

    the model itself, so neither the UI nor the server can be made to run two scans at once.



    Stale results are cleared at the same time - leaving the previous batch's verdict on screen

    under a "loading" banner is how someone reads the wrong answer for the wrong file.

    """
    runtime = runtime or mimo.BACKEND
    n = max(1, int(per_batch or 1))
    return (gr.update(interactive=False, value="Checking…"),
            f"### Loading…\n\nMiMo is reading up to **{n} region(s)** on the **{runtime}** "
            f"runtime — roughly {fmt_eta(n, runtime)} once the model is in memory. The first scan "
            f"after a restart also downloads the weights, which takes several minutes longer.\n\n"
            f"_Leave this tab open; the report replaces this message when it is done._",
            [], [], "")


def finish():
    """Give the button back. Chained with `.then()`, so it runs even if the scan raised."""
    return gr.update(interactive=True, value="Check this batch")


def build_report(results, idx, groups, n_candidates, runtime) -> str:
    """The verdict for this batch, and an explicit account of what is still unread."""
    if not results:
        return "Nothing was read."

    hits = [(w, r) for w, r in results if r["pred_injected"]]
    unparsed = sum(1 for _, r in results if not r["parse_ok"])
    read = len(results)
    unread = n_candidates - read
    others = [i for i in range(len(groups)) if i != idx]

    if hits:
        fams = sorted({r["pred_family"] for _, r in hits if r["pred_family"] != "none"})
        head = (f"## Payload found in batch {idx + 1}\n\n"
                f"> ### ⚠️ Instructions: do not open the file. Erase immediately!\n\n"
                f"MiMo flagged **{len(hits)} of the {read} region(s)** in this batch.")
        head += (f" It named the family as **{', '.join(fams)}** — correct 43% of the time in "
                 f"Part B, so treat it as a suggestion." if fams
                 else " It did not commit to a family.")

        # Treatment goes directly under the verdict, because it is the only part of this report
        # anyone acts on. One block per named family; a flagged region with no family still gets
        # the generic containment advice rather than silence.
        head += "\n\n### What to do about it\n"
        if fams:
            for family in fams:
                head += f"\n**{family}**\n\n{TREATMENT[family]}\n"
            named = "family" if len(fams) == 1 else "families"
            head += (f"\n_The {named} named above {'is' if len(fams) == 1 else 'are'} MiMo's "
                     f"guess, right about 43% of the time — so this advice may be the remedy for a "
                     f"different attack. The containment step in each first sentence holds either "
                     f"way._")
        else:
            head += f"\n{TREATMENT_UNNAMED}\n"
    else:
        head = (f"## Nothing found in batch {idx + 1}\n\nMiMo read **{read} region(s)** in this "
                f"batch and flagged none of them.")

    caveats = []
    if unread > 0:
        caveats.append(
            f"**This is 1 of {len(groups)} batches.** {unread} region(s) across "
            f"{len(others)} other batch(es) have not been read. Whatever this batch says, it says "
            f"it about {read} of the file's {n_candidates} candidate regions — nothing more.")
    if unparsed:
        sweep_bad = sum(1 for w, r in results if not r["parse_ok"] and w["source"] == "sweep")
        note = (f"{unparsed} answer(s) could not be parsed and count as *not injected*, exactly as "
                f"Part B scored them (155 of 1,100 there).")
        if sweep_bad:
            note += (
                f" **{sweep_bad} of those were sweep regions**, and that is expected rather than "
                f"surprising: Part B only ever showed MiMo marker-centred windows or the head of a "
                f"document, never arbitrary mid-file content streams. Given a page of font "
                f"positioning operators it tends to carry on copying the input instead of "
                f"answering. Sweep regions buy coverage of text that would otherwise never be "
                f"looked at; they do not inherit Part B's accuracy, and a *clean* verdict on one "
                f"is close to no evidence at all.")
        caveats.append(note)

    body = head
    if caveats:
        body += "\n\n" + "\n\n".join("- " + c for c in caveats)
    body += (f"\n\n---\n\n**On the corpus Part B measured**, MiMo scored F1 {PART_B['f1']}, "
             f"precision {PART_B['precision']}, recall {PART_B['recall']} on {PART_B['n']:,} "
             f"documents that were 82% injected — where a detector that flags everything without "
             f"reading it scores F1 0.900. Read 0.945 against 0.900, not against zero.\n\n"
             f"_Runtime: **{runtime}**. {mimo.CAVEATS[runtime]}_")
    return body


INTRO = f"""

# PDF Injection Detector — MiMo-7B



Upload a PDF. It is rendered to text with the extractor that built the project corpus, the regions

carrying structural markers are ranked and cut into **batches that fit one run of the model**, and

**MiMo-7B-RL** reads the batch you choose — reporting whether a payload is hidden there, and the

substring that convinced it.



Batching is what keeps a long document inside the runtime's limit: one batch is one run, and you

decide how many runs to spend. The report always states how much of the file is still unread.



**None of what it tells you is guaranteed correct — not the verdict, not the family, not the

treatment.** On the corpus it was measured against, MiMo got the injected/clean call right often

enough to score F1 0.945, but it named the attack family correctly only **43%** of the time — so

more often than not the family shown, and therefore the remediation advice attached to it, belongs

to a different attack. A clean verdict is not proof of a clean file either. Read every output as a

prompt to look closer yourself, never as a decision that has already been made.



This is a coursework artefact built on a synthetic corpus of 1,100 PDFs carrying harmless

EICAR/AMTSO/WICAR/RANSIM test markers. **It is not a general malware scanner**, and real malware

does not announce itself the way these samples do.



**Why MiMo and not Gemma?** Part B's winner was Gemma-2-9B at F1 0.969, against MiMo's 0.945. But

Gemma is gated behind a licence and a token, and it is 2.6× slower per window (10.95 s vs 4.18 s).

On free ZeroGPU — one grant capped at 300 s, and roughly five minutes of GPU per day — that is the

difference between a working demo and one that refuses strangers at the door and then runs out of

quota. The cost of the swap is 0.024 F1 and family-naming dropping from 63% to 43%.



Running on the **{mimo.BACKEND.upper()}** runtime

({'4-bit NF4 — Part B’s own configuration' if GPU else 'Q4_K_M GGUF via llama.cpp'}),

about {mimo.SECONDS_PER_WINDOW:g}s per region.

"""


with gr.Blocks(title="PDF Injection Detector") as demo:
    gr.Markdown(INTRO)
    state = gr.State()

    with gr.Row():
        # The example rail, down the left edge. Plain buttons rather than `gr.Examples`: the
        # built-in renders a horizontal table of filenames, and what is wanted here is one
        # labelled tab per attack type that loads its document on click. Buttons also sidestep
        # `gr.Examples`' caching, which would run a full scan of all thirteen files at startup
        # and spend the whole day's ZeroGPU quota before anyone opened the page.
        with gr.Column(scale=1, min_width=170):
            if EXAMPLES:
                gr.Markdown("### Examples\nOne document per attack type.")
                example_buttons = [(gr.Button(label.replace("_", " "), size="sm"), path)
                                   for label, path in EXAMPLES]
            else:
                example_buttons = []

        with gr.Column(scale=2):
            pdf = gr.File(label="PDF", file_types=[".pdf"], type="filepath")
            # Offered rather than decided, because the GPU here is the scarce resource: a free
            # account gets ~5 minutes of ZeroGPU a day and one batch reserves most of a run. The
            # CPU path is ~30x slower and has no quota at all, which makes it the right answer
            # once the day's GPU is gone - the Space should not go dark until midnight.
            runtime_pick = gr.Radio(
                choices=mimo.BACKENDS, value=mimo.BACKEND, label="Runtime",
                visible=len(mimo.BACKENDS) > 1,
                info=("gpu = 4-bit NF4, Part B's own configuration, ~4s/region, limited by your "
                      "daily ZeroGPU quota. cpu = Q4_K_M GGUF via llama.cpp, ~2min/region, "
                      "unlimited."))
            per_batch = gr.Slider(1, MAX_PER_BATCH, value=DEFAULT_PER_BATCH, step=1,
                                  label="Regions per batch",
                                  info=(f"How many regions one run of the model reads — it just "
                                        f"re-cuts the same list, so fewer per batch means more "
                                        f"batches. A run reserves the same GPU time whatever this "
                                        f"is set to, so lowering it inspects less of the file for "
                                        f"the same quota. Leave it at {MAX_PER_BATCH} unless you "
                                        f"want a faster single run."))
            # allow_custom_value: the choices are empty until a PDF is uploaded, and without this
            # Gradio validates any incoming value against that empty list and rejects it - which
            # makes the batch un-selectable over the API even though the UI had populated it.
            # `resolve_batch` below is what actually decides the index, from the file itself.
            batch_pick = gr.Dropdown(label="Batch to check", choices=[], interactive=False,
                                     allow_custom_value=True,
                                     info="Each batch is a separate run — spend as many as you "
                                          "like.")
            sweep = gr.Checkbox(
                value=True, label="Sweep the rest of the document too",
                info="Off = marker regions only, which is the shape Part B measured. On = the "
                     "batches cover the whole file, at the cost of regions MiMo often will not "
                     "answer about.")
            want_nb = gr.Checkbox(value=True, label="Also show the nearest files in the corpus",
                                  info="Adds a one-off 550 MB embedding-model download.")
            go = gr.Button("Check this batch", variant="primary")
            plan = gr.Markdown("Upload a PDF to see what will be read.")

        with gr.Column(scale=4):
            with gr.Tab("Report"):
                report = gr.Markdown()
            with gr.Tab("Regions read"):
                window_table = gr.Dataframe(headers=WINDOW_COLUMNS, wrap=True, interactive=False)
            with gr.Tab("Nearest corpus files"):
                nb_table = gr.Dataframe(headers=neighbours.NEIGHBOUR_COLUMNS, interactive=False)
            with gr.Tab("What MiMo actually said"):
                # No `show_copy_button`: gradio 6 removed it, and this Space should survive an
                # sdk_version bump rather than crash at startup on a cosmetic argument.
                raw = gr.Textbox(lines=22, interactive=False,
                                 label="The prompt route and untouched generation per region")

    # Each example button just drops its path into the file component. That fires `pdf.change`
    # below, so an example goes through exactly the same triage as a real upload - there is no
    # second code path for demo files, and nothing about an example is pre-computed.
    for button, path in example_buttons:
        button.click(lambda p=str(path): p, None, pdf)

    inputs = [pdf, per_batch, sweep, runtime_pick]
    for ev in (pdf.change, per_batch.change, sweep.change, runtime_pick.change):
        ev(on_upload, inputs, [state, plan, batch_pick])
    # Three chained steps: lock and show "Loading…", scan, unlock. `.then()` rather than
    # `.success()` for the last one, because the button must come back even when the scan raised -
    # a quota refusal that left the app permanently disabled would look like a crash.
    # concurrency_limit=1 is the server-side half of the same guarantee.
    scan = go.click(begin, [per_batch, runtime_pick],
                    [go, report, window_table, nb_table, raw], queue=False)
    scan = scan.then(run, [pdf, per_batch, batch_pick, want_nb, sweep, runtime_pick],
                     [report, window_table, nb_table, raw], concurrency_limit=1)
    scan.then(finish, None, go, queue=False)

if __name__ == "__main__":
    demo.queue(max_size=8).launch()