File size: 9,631 Bytes
1c16318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""

Stage 1 - PDF processing.



PDF bytes in, the text a model is allowed to read out, cut into windows.



**Everything above `window_document` is lifted verbatim from the EDA notebook that built the

corpus** (Final_project_V7_EDA.ipynb, cells 85-90). That is not tidiness - it is the whole

correctness argument of this application. Part B measured Gemma on `payload_window` strings

produced by exactly this code. If the app extracted text even slightly differently, the published

F1 of 0.969 would stop describing this program. `tests/test_fidelity.py` re-derives the corpus

columns from the original PDFs and fails on a single differing character.

"""

import re
import zlib

import numpy as np

# --------------------------------------------------------------------------------------------
# Lifted verbatim from the corpus build. Do not "improve" anything in this block.
# --------------------------------------------------------------------------------------------

STREAM_RE = re.compile(rb"(stream\r?\n)(.*?)(endstream)", re.S)

INJECTION_MARKERS = {
    "javascript_injection":     [rb"/S\s*/JavaScript", rb"/JS\s*\("],
    "cross_site_scripting":     [rb"<script>", rb"fetch\("],
    "ssrf":                     [rb"169\.254\.169\.254", rb"/latest/meta-data"],
    "object_action_injection":  [rb"/S\s*/Launch", rb"cmd\.exe"],
    "llm_prompt_injection":     [rb"IGNORE PREVIOUS INSTRUCTIONS", rb"LLM-INJECT"],
    "shellcode_embedded_exe":   [rb"application#2Fx-msdownload", rb"MZ.{0,20}\\x90\\x90"],
    "polyglot_file":            [rb"PK\\x03\\x04", rb"POLYGLOT ZIP\+PDF"],
    "dde_template_injection":   [rb"remote\.dotm\?dde=", rb"=cmd\|"],
    "steganographic_payload":   [rb"LSB-STEGO"],
    "ransomware_simulation":    [rb"RANSIM TEST"],
    "xfa_acroform_injection":   [rb"/Type\s*/XFA", rb"xfa:data"],
    "uri_redirect_phishing":    [rb"phishing\.\w+\.test"],
}
FRAMEWORK_MARKERS = ["AMTSO", "WICAR", "OWASP", "AtomicRedTeam", "Metasploit",
                     "Glasswall", "mindcrypt", "RanSim", "RANSIM", "custom"]
BASE_PAYLOAD_MARKERS = ["EICAR-STANDARD-ANTIVIRUS-TEST-FILE", "AMTSO-POTENTIALLY-UNWANTED-TEST-FILE",
                        "WICAR-BROWSER-TEST-PAYLOAD", "RANSIM-KNOWBE4-ENCRYPTION-SIMULATION"]

# Brand identifiers a model could string-match on without reasoning about PDF structure. The
# structural markers above are deliberately NOT masked: those ARE the attack, not a giveaway label.
LEAK_STRINGS = sorted(
    set(FRAMEWORK_MARKERS) | set(BASE_PAYLOAD_MARKERS)
    | {"EICAR", "RANSIM", "eicar-standard-antivirus-test-file",
       r"X5O!P%@AP[4\PZX54(P^)7CC)7}$"},
    key=len, reverse=True)            # longest first: "EICAR-STANDARD-..." masks before "EICAR"
LEAK_RE = re.compile("|".join(re.escape(s) for s in LEAK_STRINGS), re.IGNORECASE)

STREAM_BODY_CAP      = 4_096      # chars kept from any single stream body
SKELETON_CHAR_BUDGET = 120_000    # ~30k tokens
HEAD_SHARE           = 0.45       # of the budget; the rest is the tail window
SCAN_WINDOW          = 8_000_000  # bytes scanned from each end for markers

CLEAN_RE  = re.compile(r"[^\x20-\x7e\n]")
SPACES_RE = re.compile(r"[ ]{4,}")


def _printable_frac(chunk: bytes, sample: int = 200_000) -> float:
    """Fraction of bytes that are ordinary printable ASCII."""
    if not chunk:
        return 1.0
    arr = np.frombuffer(chunk[:sample], dtype=np.uint8)
    ok = ((arr >= 32) & (arr < 127)) | (arr == 9) | (arr == 10) | (arr == 13)
    return float(ok.mean())


def build_skeleton(data: bytes):
    """Render a PDF as payload-preserving text. Returns (skeleton, was_truncated, n_binary_dropped)."""
    dropped = 0

    def replace(match):
        nonlocal dropped
        opener, body, closer = match.group(1), match.group(2), match.group(3)
        try:                                            # most streams are FlateDecode
            inflated = zlib.decompress(body)
            if _printable_frac(inflated) > 0.6:
                return opener + inflated[:STREAM_BODY_CAP] + b"\n" + closer
        except zlib.error:
            pass
        if _printable_frac(body) > 0.6:                 # already plain text
            return opener + body[:STREAM_BODY_CAP] + closer
        dropped += 1                                    # genuinely binary (an image)
        return opener + b"<<BINARY %d bytes>>" % len(body) + closer

    text = STREAM_RE.sub(replace, data).decode("latin-1")

    truncated = len(text) > SKELETON_CHAR_BUDGET
    if truncated:
        # Head AND tail: the two insertion strategies put payloads at opposite ends of the file,
        # so a plain head truncation would lose most of them.
        head_n = int(SKELETON_CHAR_BUDGET * HEAD_SHARE)
        tail_n = SKELETON_CHAR_BUDGET - head_n
        elided = len(text) - SKELETON_CHAR_BUDGET
        text = (text[:head_n * 2]
                + f"\n<<... {elided} characters elided ...>>\n"
                + text[-tail_n * 2:])

    text = CLEAN_RE.sub(" ", text)                      # drop control/binary residue
    text = SPACES_RE.sub("   ", text)

    if len(text) > SKELETON_CHAR_BUDGET:                # enforce the budget after cleanup
        head_n = int(SKELETON_CHAR_BUDGET * HEAD_SHARE)
        text = text[:head_n] + text[-(SKELETON_CHAR_BUDGET - head_n):]
        truncated = True
    return text, truncated, dropped


def mask_leaks(skeleton: str) -> str:
    """Blank the brand identifiers, keep the structural shape of the injection intact."""
    return LEAK_RE.sub(lambda m: "X" * len(m.group(0)), skeleton)


def scan_window(data: bytes, window: int = SCAN_WINDOW) -> bytes:
    """Head and tail of a file, for the regex marker scans."""
    if len(data) <= 2 * window:
        return data
    return data[:window] + data[-window:]


def detect_markers(blob) -> list:
    """Which injection types are structurally present. Works on raw bytes or on a skeleton."""
    data = blob.encode("latin-1", errors="replace") if isinstance(blob, str) else blob
    data = scan_window(data)
    return [name for name, patterns in INJECTION_MARKERS.items()
            if any(re.search(p, data, re.IGNORECASE) for p in patterns)]


ANY_MARKER_RE = re.compile(
    b"|".join([p for ps in INJECTION_MARKERS.values() for p in ps]
              + [re.escape(s).encode() for s in FRAMEWORK_MARKERS + BASE_PAYLOAD_MARKERS]),
    re.IGNORECASE)

# --------------------------------------------------------------------------------------------
# The application's own code starts here.
# --------------------------------------------------------------------------------------------

WINDOW = 1_500                 # characters either side; a window is 2*WINDOW = 3,000 chars
STRIDE = WINDOW                # 50% overlap, so no payload can straddle a boundary unseen

# 120,000-char skeleton budget / 1,500 stride caps this at ~80 windows for any document.
MAX_WINDOWS = SKELETON_CHAR_BUDGET // STRIDE + 1


def payload_window(text, half=WINDOW):
    """

    The evaluation's window: the neighbourhood of the *known* injection.



    THIS IS THE ANSWER KEY. It locates the payload by searching for the markers the corpus was

    generated with, so it only works on corpus files. On a PDF a user uploads nothing matches and

    every document falls through to the head - which the EDA measured as a bad detector, since

    only 40.5% of injected files carry their payload in the first 3,000 characters.



    Kept because `tests/test_fidelity.py` needs it to reproduce the published column, and for

    scoring corpus files. Never call it on user input; call `window_document` instead.

    """
    m = ANY_MARKER_RE.search(text.encode("latin-1", errors="replace"))
    if not m:
        return text[:2 * half]
    return text[max(0, m.start() - half): m.start() + half]


def pdf_to_text(data: bytes) -> dict:
    """One uploaded PDF, rendered the way the corpus was rendered."""
    skeleton, truncated, dropped = build_skeleton(data)
    return {
        "skeleton": skeleton,
        "masked": mask_leaks(skeleton),
        "chars": len(skeleton),
        "was_truncated": truncated,
        "binary_streams_dropped": dropped,
        "file_bytes": len(data),
    }


def window_document(text: str, size: int = 2 * WINDOW, stride: int = STRIDE) -> list:
    """

    Cut a document into the input shape Part B measured, covering all of it.



    Returns [{"index", "start", "end", "text"}]. Windows are `size` characters at `stride`

    intervals, so consecutive windows overlap by half. The overlap is deliberate: a payload

    sitting across a boundary would otherwise be split into two halves, neither of which reads

    as an attack. It also means **one payload normally appears in two windows**, which is why

    `analysis.py` merges overlapping hits into regions before counting them.



    The final window is anchored to the end of the text rather than left short, so the tail -

    where the EDA found 63.4% of injections - is always covered at full width.

    """
    text = text or ""
    if not text:
        return []
    if len(text) <= size:
        return [{"index": 0, "start": 0, "end": len(text), "text": text}]

    starts = list(range(0, len(text) - size + 1, stride))
    if starts[-1] + size < len(text):
        starts.append(len(text) - size)          # anchored tail window

    return [{"index": i, "start": s, "end": s + size, "text": text[s:s + size]}
            for i, s in enumerate(starts[:MAX_WINDOWS])]