Skip to main content

DocGuard — Multi-Format Document Security Scanner

DocGuard is a Python security pipeline that statically analyzes documents submitted by external suppliers and automatically routes them based on risk. Suspicious files are quarantined; clean files are forwarded with macros intact for downstream use.

It is packaged as a pip-installable library so it can be imported directly into existing company projects.


Content-Based Routing

As of this version, DocGuard determines a file's format from its actual content, not its filename extension. Previously, a malicious file renamed from payload.exe to invoice.pdf would be routed to PDFScanner purely because of the .pdf name — the file's real content was never verified.

FileRouter now sniffs the file (via docguard/content_sniffer.py) using:

  • Magic bytes for binary formats — PDF (%PDF-), PNG, JPEG, LNK, ZIP, OLE2
  • ZIP-internal inspection for OOXML — reads [Content_Types].xml to distinguish .docx/.xlsx/.docm/.xlsm/.dotm/.xltm/.xlsb, since they all share the same outer ZIP signature
  • OLE2-internal inspection for legacy Office — reads internal stream names (WordDocument, Workbook/Book, MAPI property streams) to distinguish .doc/.xls/.msg, since they all share the same outer OLE2 signature
  • Structural heuristics for text formats without magic bytes — .xml (<?xml), .html (<!doctype html), .url ([InternetShortcut]), .eml (RFC822 headers)

No new heavy dependency (no python-magic/libmagic) was introduced — olefile is already a transitive dependency of oletools, so detection stays dependency-light and Windows-install-friendly.

When content and extension disagree: the file is still scanned and routed according to its real detected content (not silently trusted based on the filename), and an Extension_Content_Mismatch finding (weight 50) is added to the top of the findings list — so the disguise itself becomes part of the audit trail and risk score, on top of whatever the real scanner finds.

The one genuine limitation: CSV vs Markdown

.csv and .md are the two formats with no reliable content signature at all — plain delimited text and plain prose share no structural markers a sniffer can key on. classify_ambiguous_text() uses lightweight heuristics (consistent delimiter count across rows vs. Markdown syntax like #, ```, [text](url)) and falls back to the claimed extension when the signal is genuinely inconclusive, rather than guessing. This is an intentional, documented gap — not an oversight.

If content cannot be identified as anything

A file whose content matches no signature at all, and isn't a plausible ambiguous-text fallback, is rejected outright with a clear error — DocGuard no longer silently scans unidentifiable content just because its extension looked legitimate.


Supported File Formats

Format Extensions Scanner
Excel (macro-enabled) .xlsm .xls .xlsb .xltm VBA + YARA + XLM
Word (macro-enabled) .doc .docm .dotm VBA + YARA
PDF .pdf JS/action detection
Images .jpg .jpeg .png Binary + EXIF + decompression bomb scan
Office Open XML .docx .xlsx DDE + relationship + HTML-injection scan
Outlook / MIME email .msg .eml Phishing + attachment + oversized-file scan
Markdown / text .md Link + script injection scan
CSV data .csv Formula-injection scan
XML data .xml XXE + malformed-XML scan
HTML .html Dangerous markup + event-handler scan
Windows shortcut .lnk Magic-byte + dangerous-target scan
Internet shortcut .url Dangerous URL-scheme scan

Installation

Requirements: Python 3.12

# Clone the repository
git clone https://github.com/niyabraham/DocGuard.git
cd DocGuard

# Install the package
py -3.12 -m pip install -e .

All dependencies (oletools, yara-python, pypdf, Pillow, openpyxl, extract-msg, XLMMacroDeobfuscator) are installed automatically.


CLI Usage

Scan a file

docguard sample_files\invoice.xlsm --quarantine-dir quarantine

Output:

[*] Initializing DocGuard Pipeline for: sample_files\invoice.xlsm
[+] Scan Complete.
    - Format         : Excel Workbook (Macro-enabled)
    - Verdict        : BLOCKED
    - Risk Score     : 400 / threshold 50
    - Destination    : quarantine\invoice_quarantined_20260811120000.xlsm
    - Findings Count : 13

Files scoring at or above the per-format threshold are BLOCKED and copied into --quarantine-dir (if given — omit it for a results-only outcome, see Results-only mode below). Files scoring below the threshold are CLEAN and copied into --clean-output-dir with macros intact (if given — same rule, no default location).

Triage quarantined files

docguard --triage

Lists every quarantined file with its SHA-256 hash, file size, risk score, findings count, and scan timestamp — ready for analyst review.


Python API Usage

Import DocGuard directly into an existing project:

from docguard import DocGuardPipeline

result = DocGuardPipeline("path/to/supplier_file.xlsm").execute()

print(result["verdict"])        # "CLEAN" or "BLOCKED"
print(result["risk_score"])     # e.g. 400
print(result["risk_threshold"]) # e.g. 50
print(result["format"])         # e.g. "Excel Workbook (Macro-enabled)"
print(result["file_copied"])    # True/False — whether a physical copy was made
print(result["destination"])    # path where file was copied, or None

for finding in result["findings"]:
    print(finding["rule"], finding["weight"], finding["desc"])

Results-only mode

Neither quarantine_dir nor clean_output_dir has a default location — DocGuardPipeline only copies a file if the matching path was actually given, for either verdict. This copy step is optional — the scan result (verdict, risk score, findings, audit log entry) is the actual deliverable of a scan, and a host application that already manages its own file storage shouldn't be forced to accept a duplicate copy of every file it submits, BLOCKED or CLEAN.

# Results-only: nothing is copied anywhere, for either verdict
result = DocGuardPipeline("supplier_file.xlsm").execute()

# verdict, risk_score, and findings are fully computed exactly as before
# result["file_copied"] is False, result["destination"] is None
# the scan is STILL logged in full to logs/audit_log.jsonl — that part
# is unconditional, since storing the result is the core objective

# Copy a BLOCKED file into one directory, a CLEAN file into another —
# independent of each other, each opt-in on its own terms
result = DocGuardPipeline(
    "supplier_file.xlsm",
    quarantine_dir="D:/quarantine",
    clean_output_dir="D:/clean_output",
).execute()

Equivalent CLI flags:

docguard supplier_file.xlsm                                                          # results-only, nothing copied
docguard supplier_file.xlsm --quarantine-dir D:\quarantine                            # BLOCKED files copied here
docguard supplier_file.xlsm --clean-output-dir D:\clean_output                        # CLEAN files copied here
docguard supplier_file.xlsm --quarantine-dir D:\quarantine --clean-output-dir D:\clean # both, independently

--triage only reflects scans that were actually copied into a quarantine directory, since it lists files that physically exist there — pass the same --quarantine-dir you scanned with.

Other importable classes

from docguard import FileRouter        # route a file to its scanner
from docguard import QuarantineManager # manage quarantine directory
from docguard import MacroDeobfuscator # deobfuscate VBA snippets directly
from docguard import FORMAT_ROUTER     # dict of supported extensions
from docguard import detect_format     # sniff a file's real content type

Analysis Pipeline

Every file passes through a six-layer analysis pipeline:

DocGuardPipeline.run()
│
├── FileRouter            — selects scanner and threshold by detected content type
│
├── Scanner.analyze()
│   ├── Layer 1: VBA extraction (olevba) — keyword scoring
│   ├── Layer 2: Deobfuscation — Chr() decode, concat collapse, base64
│   ├── Layer 3: XLM / Excel 4.0 macro detection
│   ├── Layer 4: Custom rule matching (rules/macro_rules.json)
│   ├── Layer 5: YARA signature matching (rules/yara/*.yar)
│   └── Layer 6: Keyword co-occurrence heuristic
│
├── Second-pass deobfuscation (base64 feedback into risk score)
│
├── Verdict & routing
│   ├── score >= threshold → BLOCKED → quarantine_dir, if one was given
│   └── score <  threshold → CLEAN  → clean_output_dir, if one was given
│
└── Audit log → logs/audit_log.jsonl

Layer 2 runs before Layers 4–6. This is the critical architectural decision: deobfuscated strings (e.g. Chr() sequences reconstructed into "Shell") are visible to all downstream rule matching. Without this ordering, obfuscated macros evade detection entirely.


Detection Layers in Detail

Layer 1 — VBA Extraction (olevba)

Extracts all VBA macro code streams and scores keywords by type:

  • AutoExec triggers (Auto_Open, Workbook_Open, Document_Open): weight 30
  • Dangerous API calls (Shell, CreateObject, Environ): weight 30
  • IOC keywords (cmd.exe, payload.exe): weight 30
  • General suspicious keywords: weight 10

Layer 2 — Deobfuscation

Reconstructs hidden strings before any rule matching:

  • Chr() decoding: Chr(83) & Chr(104) & Chr(101) & Chr(108) & Chr(108) → "Shell"
  • String concatenation collapse: "She" & "ll" → "Shell"
  • Base64 extraction: detects and decodes embedded base64 payloads

Layer 3 — XLM / Excel 4.0 Macro Detection

Detects legacy Excel 4.0 macro sheets (XLM), which are a separate execution path from VBA and commonly used to evade VBA-only scanners.

Layer 4 — Custom Rule Matching

Weighted substring rules defined in rules/macro_rules.json. Externalized so new rules can be added without touching Python code.

Layer 5 — YARA Signatures

33 YARA rules across 6 files covering:

File Coverage
vba_autoexec.yar Auto-execution entry points
vba_shell_execution.yar Shell, PowerShell, CMD, COM object abuse
vba_download_cradles.yar HTTP download, WebClient, URLDownloadToFile
vba_obfuscation.yar Chr(), base64, StrReverse, hex construction
vba_persistence.yar Registry Run keys, startup folder, scheduled tasks
vba_process_injection.yar VirtualAlloc, WriteProcessMemory, NT APIs

Each YARA rule carries its own weight in rule metadata — no weights are hardcoded in Python.

Layer 6 — Keyword Co-occurrence Heuristic

Fires when 2 or more behavioural trigger keywords appear together in the same document (shell, createobject, wscript.shell, environ, exec, powershell). Co-occurrence signals chained behaviour rather than isolated keyword presence: weight 40.


Per-Format Risk Thresholds

Thresholds vary by format because threat severity and false-positive rates differ:

Format Threshold Rationale
Macro-enabled Office 50 Highest risk — direct code execution
PDF 60 Higher FP rate — many legitimate PDFs use JS/forms
OOXML (docx/xlsx) 55 DDE and external links — moderate risk
Outlook email 55 Phishing + attachment risk
Images 65 Rare threat vector — polyglot/EXIF abuse
Markdown 70 Lowest risk — requires most evidence to block

Output Directories

Both are optional and only created when actually needed — neither has a default. Pass --quarantine-dir PATH to have a BLOCKED file copied there, and/or --clean-output-dir PATH to have a CLEAN file copied there. Omit either flag and that verdict's file is scanned and logged, not copied. Either directory is created automatically in the caller's working directory — not inside the installed package — the first time a file actually needs to land in it, never just because a path was configured.

<quarantine_dir>    BLOCKED files copied here (original preserved at source), only if given
<clean_output_dir>  CLEAN files copied here with macros intact, only if given
logs/               audit_log.jsonl — append-only scan records, ALWAYS written

logs/ is the one directory that's always created and always written to, regardless of quarantine_dir/clean_output_dir — storing the scan result is DocGuard's core objective; copying the file itself is a convenience on top of that, opt-in per verdict.

Audit Log Format

Every scan appends one JSON line to logs/audit_log.jsonl:

{
    "timestamp": "2026-08-11 23:57:32",
    "filename": "chr_obfuscated_macro.xlsm",
    "file_path": "C:\\...\\sample_files\\chr_obfuscated_macro.xlsm",
    "format": "Excel Workbook (Macro-enabled)",
    "verdict": "BLOCKED",
    "risk_score": 400,
    "risk_threshold": 50,
    "destination": "C:\\...\\quarantine\\chr_obfuscated_macro_quarantined_20260811235732.xlsm",
    "findings_count": 13,
    "findings": [...]
}

Project Structure

docguard/
├── pyproject.toml                  pip packaging configuration
├── generate_test_samples.py        generates 11 test files (dev only)
│
├── docguard/                     installable package
│   ├── __init__.py                 public API (DocGuardPipeline, FileRouter, ...)
│   ├── __main__.py                 enables python -m docguard
│   ├── cli.py                      docguard console script entry point
│   ├── pipeline.py                 end-to-end orchestration + audit logging
│   ├── file_router.py              format detection and scanner dispatch
│   ├── deobfuscator.py             Chr(), concat, base64 reconstruction
│   ├── quarantine_manager.py       file isolation + triage reporting
│   │
│   ├── scanners/
│   │   ├── macro_scanner.py        VBA + XLM + YARA (Excel/Word macro files)
│   │   ├── pdf_scanner.py          PDF JS/action/URL analysis
│   │   ├── office_scanner.py       OOXML DDE + relationship analysis
│   │   ├── image_scanner.py        binary + EXIF metadata analysis
│   │   ├── email_scanner.py        phishing + attachment analysis
│   │   └── text_scanner.py         link + script injection analysis
│   │
│   └── rules/
│       ├── macro_rules.json        weighted custom detection rules
│       └── yara/
│           ├── vba_autoexec.yar
│           ├── vba_shell_execution.yar
│           ├── vba_download_cradles.yar
│           ├── vba_obfuscation.yar
│           ├── vba_persistence.yar
│           └── vba_process_injection.yar
│
├── sample_files/                   test documents (generated by generate_test_samples.py)
├── <quarantine_dir>/               blocked files (created at runtime, only if --quarantine-dir given)
├── clean_output/                   clean files (created at runtime, only if --clean-output-dir given)
└── logs/                           audit_log.jsonl (created at runtime)

Adding Detection Rules

Custom rules (no Python required)

Edit docguard/rules/macro_rules.json:

{
  "rules": [
    {
      "name": "My_New_Rule",
      "weight": 40,
      "patterns": ["SuspiciousFunction", "DangerousKeyword"],
      "description": "Detects my new threat pattern."
    }
  ]
}

YARA rules

Add a .yar file to docguard/rules/yara/. It is picked up automatically on the next run — no Python changes needed. Include a weight field in rule metadata:

rule My_New_YARA_Rule
{
    meta:
        description = "Detects something suspicious"
        weight      = 50

    strings:
        $s1 = "SuspiciousString" nocase

    condition:
        any of them
}

Generating Test Samples

# Install COM automation support (Windows only, required for .xlsm/.xls/.docm/.doc)
py -3.12 -m pip install pywin32

# Enable VBA project access in Excel and Word:
# File → Options → Trust Center → Trust Center Settings
# → Macro Settings → check "Trust access to the VBA project object model"

py -3.12 generate_test_samples.py

Generates 11 test files covering all supported formats with realistic malicious patterns.


Dependencies

Package Purpose
oletools VBA macro extraction (olevba)
yara-python YARA signature matching
XLMMacroDeobfuscator Excel 4.0 / XLM macro detection
pypdf PDF structural analysis
Pillow Image EXIF metadata scanning
openpyxl XLSX content analysis
extract-msg Outlook OLE2 .msg parsing

Gap Analysis Against validators.py

The company's existing validators.py (used as the synchronous upload gate) was reviewed against DocGuard's checks. The following checks existed in validators.py but not in DocGuard, and have now been added:

Check Where it was added
Image decompression bomb (pixel count > 50MP) image_scanner.py
Expanded HTML-injection patterns (vbscript:, onclick=, onmouseover=, data:text/html, <embed>) text_scanner.py
Script/HTML-injection scan inside OOXML XML content office_scanner.py
CSV formula-injection scan new csv_scanner.py
XML XXE / malformed-XML scan new xml_scanner.py
HTML dangerous-markup scan new html_scanner.py
Windows shortcut (.lnk) magic-byte + dangerous-target scan new shortcut_scanner.py
Internet shortcut (.url) dangerous-scheme scan new shortcut_scanner.py
.eml registered as a distinct routable extension file_router.py
MSG oversized-file check (50MB) email_scanner.py

Checks already present in DocGuard that exceed validators.py's equivalent (VBA/YARA macro analysis, PDF structural analysis, co-occurrence heuristics, Chr()/base64 deobfuscation) were left unchanged — validators.py's versions of those checks are a subset of what DocGuard already does.

One structural difference from validators.py remains, but by design rather than by gap: validators.py uses python-magic (libmagic) for its MIME-vs-extension cross-check, while DocGuard's own content_sniffer.py — magic bytes, ZIP-internal [Content_Types].xml inspection, and OLE2 stream-name inspection, see Content-Based Routing above — reaches the same end (route on real content, not the filename) without a libmagic system dependency, which avoids Windows install friction. Both approaches close the same gap; this one just doesn't need a compiled system library to do it.


Known Limitations

  • ViperMonkey (VBA emulation) is not integrated due to a pyparsing version conflict with Python 3.12. It is documented as a future roadmap item.
  • The PDF test sample produces a benign incorrect startxref pointer warning from pypdf — this is an artifact of the hand-crafted test file, not a scanner bug.
  • generate_test_samples.py requires a Windows machine with Excel and Word installed for the COM-dependent file types (.xlsm, .xls, .docm, .doc). The remaining 6 formats generate on any platform.

Metadata

Release files for docguard-scanner 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for docguard-scanner 1.2.0
File Size Uploaded
docguard_scanner-1.2.0.tar.gz 58.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for docguard-scanner 1.2.0
File Interpreter ABI Platform
docguard_scanner-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 120.5 kB

Release files / docguard_scanner-1.2.0.tar.gz

Download URL docguard_scanner-1.2.0.tar.gz
Size 58.9 kB
Tags Source
SHA-256 checksum
How to use checksums
b7ada7691780c5102c6875aa1c909b6ea96c7e146efbf0f62b26e4c843bc96c8
BLAKE2b-256 checksum
How to use checksums
00c36fb1d1e8502f13f0a2fc72e9690faf2d8cdc979ae5b5219cee9f9a60870b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.8

Release files / docguard_scanner-1.2.0-py3-none-any.whl

Download URL docguard_scanner-1.2.0-py3-none-any.whl
Size 61.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
666d4c9b159df755834724c406ac1548f02aa4a0eb4f322044ec7baf201ff0ee
BLAKE2b-256 checksum
How to use checksums
daa894c0771aa250a2f9f4cb2a9c7e0439e4aba0b1454f672dac7f22ebcb54fb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.8

Release history Release notifications | RSS feed

1.2.1

2 release files

This release

1.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page