Mimo_Injection_detector / doc_features.py
BentoUniAcc's picture
Add the family-naming model as stage 4; remove the gpu/cpu runtime choice
4a6ccb0 verified
Raw
History Blame Contribute Delete
6.15 kB
"""
Stage 4a - the seventeen numbers that describe the shape of a document.
These are the `document shape` block of the family-naming model's feature vector, and every one of
them is **lifted verbatim from the corpus build** (`HARMLESS_Synthetic_Injected_PDFs_EDA/
Final_project_V7_EDA.ipynb`, cells 89-90, `extract_one`). That is the correctness argument for this
file: the model was fitted on columns produced by that function, so a feature computed even
slightly differently here is a different variable wearing the same name, and the model would score
it confidently and wrongly.
`corpus_text.py` already carries the text half of that same cell (the skeleton builder, the marker
alternation). It is imported rather than copied, so the two halves cannot drift apart.
Do not "improve" the arithmetic in this file. If a definition here looks odd - `n_obj` counting the
string " obj" rather than parsing the xref table, `n_distinct_names` reading only the first 2 MB -
that oddity is in the training data too, and reproducing it is the whole point.
"""
import re
import numpy as np
import corpus_text
# EDA cell 89. `STREAM_RE`, `_printable_frac` and the skeleton builder live in `corpus_text`.
NAME_RE = re.compile(rb"/[A-Za-z][A-Za-z0-9#]{1,30}")
IMAGE_RE = re.compile(rb"/Subtype\s*/Image")
BRACE_RE = re.compile(rb"<<|>>")
# The order the model was fitted on. `family_model.py` slices by position, so this list is a
# schema, not a convenience - it is asserted against the model's own spec file at import.
NUMERIC = ["file_size", "n_obj", "n_stream", "n_objstm", "n_images", "n_distinct_names",
"max_dict_depth", "printable_frac", "entropy_file", "entropy_largest_stream",
"largest_stream_bytes", "compression_ratio", "binary_streams_dropped",
"n_pages", "page_text_chars", "skeleton_chars", "was_truncated"]
# Heavy-tailed byte counts got a log companion in training: trees do not need one, and the
# logistic-regression baseline the model was compared against did.
LOGGED = ["file_size", "largest_stream_bytes", "page_text_chars"]
def entropy(chunk: bytes, sample: int = 1_000_000) -> float:
"""Shannon entropy in bits/byte. 8.0 = incompressible; high values mean packed or encrypted."""
if not chunk:
return 0.0
counts = np.bincount(np.frombuffer(chunk[:sample], dtype=np.uint8),
minlength=256).astype(np.float64)
counts = counts[counts > 0]
p = counts / counts.sum()
return float(-(p * np.log2(p)).sum())
def max_dict_depth(data: bytes, limit: int = 2_000_000) -> int:
"""
Deepest nesting of PDF dictionaries (<< >>) - a cheap proxy for object complexity.
Every '<<' is +1 and every '>>' is -1; the answer is the running maximum of the cumulative sum.
"""
steps = np.array([1 if m.group() == b"<<" else -1
for m in BRACE_RE.finditer(data[:limit])], dtype=np.int32)
if steps.size == 0:
return 0
return int(np.maximum.accumulate(np.cumsum(steps)).max())
def page_stats(path):
"""
Page count and visible text length, via PyMuPDF.
These two describe the *carrier* document - the innocent PDF the payload was injected into -
rather than the payload, which lives in the object structure and adds almost no visible text.
They were controls in the EDA and they are ordinary features here.
A malformed file is expected rather than exceptional in this corpus, so a parse failure returns
zeros exactly as `extract_one` recorded them, instead of propagating. The training rows for
unparseable files carried those same zeros.
"""
try:
import fitz
fitz.TOOLS.mupdf_display_errors(False)
with fitz.open(path) as doc:
return doc.page_count, sum(len(page.get_text()) for page in doc), True
except Exception:
return 0, 0, False
def describe(path, data: bytes = None, skeleton: str = None,
truncated: bool = None, dropped: int = None) -> dict:
"""
The seventeen numbers for one PDF, by the corpus's own definitions.
The skeleton is passed in when the caller already built one - `app.py` does, on upload - because
rendering a 780 KB PDF to text twice per scan is the single most expensive thing this module
could do for no reason.
"""
if data is None:
with open(path, "rb") as fh:
data = fh.read()
if skeleton is None:
skeleton, truncated, dropped = corpus_text.build_skeleton(data)
n = len(data)
streams = [m.group(2) for m in corpus_text.STREAM_RE.finditer(data)]
largest = max(streams, key=len) if streams else b""
n_pages, page_text_chars, parses_ok = page_stats(path)
return {
"file_size": n,
"n_obj": data.count(b" obj"),
"n_stream": len(streams),
"n_objstm": data.count(b"/ObjStm"),
"n_images": len(IMAGE_RE.findall(data)),
"n_distinct_names": len(set(NAME_RE.findall(data[:2_000_000]))),
"max_dict_depth": max_dict_depth(data),
"printable_frac": corpus_text._printable_frac(data, sample=n),
"entropy_file": entropy(data[:1_000_000]),
"entropy_largest_stream": entropy(largest[:1_000_000]),
"largest_stream_bytes": len(largest),
"compression_ratio": len(skeleton) / n if n else np.nan,
"binary_streams_dropped": int(dropped or 0),
"n_pages": n_pages,
"page_text_chars": page_text_chars,
"skeleton_chars": len(skeleton),
"was_truncated": bool(truncated),
# Not a model feature. Carried so the interface can say the file did not parse, which is
# worth knowing on its own and explains why the two page features are zero.
"parses_ok": parses_ok,
}
def vector(stats: dict) -> np.ndarray:
"""The seventeen numbers plus their three log companions, in the fitted order. Shape (20,)."""
base = np.array([float(stats[k]) for k in NUMERIC], dtype=np.float32)
base = np.nan_to_num(base)
logs = np.log1p(np.abs(np.array([float(stats[k]) for k in LOGGED], dtype=np.float32)))
return np.concatenate([base, np.nan_to_num(logs)])