corbel/FIX-NOTES-false-positive-re...

16 KiB
Raw Blame History

Patch notes — false-positive redesign (master)

Problem

Operators reported the scanner was generating ~130 findings on a clean technical PDF (Linux from Scratch). The findings were mostly false positives: every plain-HTTP URL, every URL whose path contained a word like "support" or "account", every URL mentioning a brand in its path, every mailto: link, every in-document cross-reference, and every paragraph mentioning wget or exploit in prose.

An earlier revision attempted to fix this by adding a hard-coded allow-list of well-known documentation domains (linuxfromscratch.org, kernel.org, github.com, etc.). This was correctly rejected by the operator as a per-file band-aid — it made the Linux-from-Scratch PDF stop alerting without solving the underlying problem, and it would produce the same false positives on every other technical document the scanner had never seen.

This revision takes the principled approach: every detector must be backed by a verifiable property, either of the document itself or of an external authority. No thresholds, no per-file or per-domain exceptions, no "suspicious" tier.

Design

Every detector in the redesigned scanner falls into exactly one of two categories.

Category 1 — Verifiable executable intent

The vector contains a structure whose only purpose is to execute code or spawn a process. Presence is the threat. There is no "benign JavaScript in a PDF action" or "benign Launch action".

Detector Triggers on Verifiable property
Active script in PDF /JavaScript or /JS action stream The action dictionary has S = JavaScript
Program launch in PDF /Launch action with /F, /Win, /Mac, /Unix The action dictionary has S = Launch
External program exec in EPUB <script> tag in XHTML The DOM contains the tag
VBA macro in DOCX word/vbaProject.xml present The file exists in the package
Executable embedded file Bytes match a known executable magic (MZ, ELF, Mach-O, OLE2, RTF, SWF, LNK, HTA, VBA stubs) at offset 0 Magic-byte match is deterministic
Executable URI scheme javascript:, vbscript:, data:text/html, file: in a hyperlink or action The scheme grammar is unambiguous
PDF form with /AA AcroForm dictionary contains /AA (Additional Actions) The dictionary key is present
PDF widget with /AA Widget annotation with /AA entry The dictionary key is present

Category 2 — Verifiable impersonation

The vector lies about identity in a way that is provably wrong.

Detector Triggers on Verifiable property
Exact-host homograph URL authority byte-equal (after ASCII case-folding) to a known homograph string (micros0ft.com, paypa1.com, etc.) Two strings are byte-equal, or they aren't
Credential URL The URI authority section (before the first / after ://) contains user:pass@ The RFC 3986 authority component has a userinfo subcomponent
Mixed-script host The URL host mixes Unicode scripts (e.g. Cyrillic 'о' inside an otherwise-Latin "microsoft.com") The script of each character is determined by char_is_cyrillic() / char_is_latin() — a property of the codepoint

Note what is not in Category 2: substring brand matching, suspicious keyword matching, phishing TLDs, IP hosts, URL shorteners. All of these were removed because they are statistical guesses about the world, not verifiable properties of the document.

Reputation (Category 3 — external authority)

Not yet implemented as a runtime check. The infrastructure is in place: external feeds can be loaded via load_external_rules() and will contribute entries to the Category 1 / Category 2 tables (homograph-host-list, signature-list, shellcode-list rule types). The previous revision's tld-list, keyword-list, and brand-list external-rule types are no longer consulted by the scanner — they are silently skipped if present in a feed file.

What was deleted

  • SUSPICIOUS_URL_KEYWORDS — substring-matched words like "login", "verify", "support", "account", "update". Every legitimate login page on Earth contains these.
  • PHISHING_TLDS — .ru, .cn, .xyz, etc. Geographically discriminatory and statistically unsound; a Russian URL is not a threat, it is a Russian URL.
  • URL_SHORTENER_DOMAINS as a threat signal — shorteners are not threats; if the destination is hostile it is caught by the underlying executable-scheme or homograph check. The constant is retained for future reputation-feed work but is no longer consulted by the scanner.
  • COMMON_PHISHING_BRANDS as a substring match — substring matching on the whole URI fired on every URL that merely mentioned a brand in its path (e.g. https://github.com/microsoft/vscode). Replaced by HOMOGRAPH_HOSTS which only ever matches the URL's authority component, byte-equal.
  • looks_like_phishing() — the whole function. Its only outputs were "found a substring we don't like" which is exactly what was removed.
  • PhishingReason enum and all of its variants (IpHost, CredentialUrl, BrandHomograph, Shortener, SuspiciousKeyword, PhishingTld). Replaced by uri_malicious_reason() which returns one of three string labels: "executable-uri-scheme", "homograph-host", "mixed-script-host", "credential-url".
  • IpHost heuristic — 192.168.1.1 is a valid network address. RFCs and router manuals reference them. Not a threat.
  • The Suspicious classification tier is no longer produced by any default detector. Config::emit_suspicious defaults to false and is retained only for API compatibility.
  • Substring text-node signatures: wget, exploit, payload, exec(, eval(, Function(, document.write, innerHTML, curl http, rm -rf, Base64.decode, atob(, powershell, cmd.exe, calc.exe, /bin/sh. All of these are words or function names that appear in legitimate technical literature. Replaced by a short list of structural signatures (/JavaScript, /JS, /Launch, /EmbeddedFile, <script, <iframe, shellcode) plus the weaponization heuristic (long hex runs, 64+ base64 chars, 2+ shell commands in non-code context).
  • DOCX double-emission of hyperlinks (one vector for visible text, one vector for URL). Now emits exactly one vector per external hyperlink, with the URL as both raw_payload and decoded_preview.
  • DOCX decoded_preview wrapping — was "rId={} target={}", which broke both scheme extraction and authority extraction. Now the decoded_preview is the URL itself.
  • PDF NeedAppearances-only AcroForm emission. NeedAppearances is a benign rendering hint present in essentially every PDF form. The parser now only emits a PdfAcroForm vector when /AA is present.
  • PdfGoToR always-Suspicious. Without inspecting the destination file we have no verifiable property to test, and "could be a threat" is not a threat. Now Benign.
  • EpubObject always-Suspicious. An <object> / <embed> / <iframe> tag is structurally an external-resource reference, not an executable hook. If the embedded resource's URL is hostile it will be caught by classify_uri on the EpubExternalResource vector that the parser emits alongside. Now Benign.
  • PdfEmbeddedFile / DocxEmbeddedObject / UnknownPayload Suspicious-by-default. A PDF with a benign attachment (sample data, image, font) is not a threat. Now Benign unless the bytes match an executable signature or shellcode prologue.

Files changed

File Change
src/core/config.rs emit_suspicious defaults to false (was true). allowed_uri_schemes now includes http and tel (was https, mailto, ftp only — every plain-HTTP URL was being flagged Malicious). Added two new fields: emit_clean_output (default true — clean docs are copied to the output folder) and move_clean_to_output (default false — move semantics are destructive). Both fields have env-var overrides (CORBEL_EMIT_CLEAN_OUTPUT, CORBEL_MOVE_CLEAN_TO_OUTPUT).
src/scanner/signatures.rs Removed SUSPICIOUS_URL_KEYWORDS, PHISHING_TLDS, COMMON_PHISHING_BRANDS tables and their matchers (match_suspicious_keyword, has_phishing_tld, match_phishing_brand, is_canonical_brand_host). Removed is_url_shortener as a threat signal. Replaced with HOMOGRAPH_HOSTS table and match_homograph_host() matcher (exact-string, host-only). External-rules feed format updated: tld-list / keyword-list / brand-list types are no longer loaded; homograph-host-list type added.
src/scanner/heuristics.rs Removed PhishingReason enum and looks_like_phishing() function. Removed IpHost, CredentialUrl, BrandHomograph, Shortener, SuspiciousKeyword, PhishingTld signal paths. Replaced with two-detector design in classify_uri(): executable-scheme check + host-impersonation check (homograph / mixed-script / credential). Added extract_uri_authority(), authority_host(), authority_has_credentials(), host_has_mixed_scripts() helpers. PdfGoToR, EpubObject, PdfEmbeddedFile-without-signature, DocxEmbeddedObject-without-signature, UnknownPayload-without-signature now classify as Benign. Added 17 new unit tests for the anti-false-positive behavior.
src/scanner/context_filter.rs Removed substring text-node signatures (wget, exploit, payload, exec(, eval(, Function(, document.write, innerHTML, curl http, rm -rf, Base64.decode, atob(, powershell, cmd.exe, calc.exe, /bin/sh). Replaced with short structural-signature list (/JavaScript, /JS, /Launch, /EmbeddedFile, <script, <iframe, shellcode). Weaponization heuristic (hex runs, base64 blobs, multi-shell-command) retained. Added 7 new unit tests asserting that prose mentioning wget / exploit / payload / eval() / powershell / /bin/sh does NOT produce a finding.
src/parsers/docx_parser.rs extract_docx_external_links() no longer emits a vector — its previous output (decoded_preview = "rId={} text={}") broke URL detection. extract_rels_external_links() is now the sole source of DOCX external-link vectors; emits one vector per hyperlink with the URL as both raw_payload and decoded_preview (no wrapping).
src/parsers/pdf_parser.rs inspect_catalog() no longer emits a PdfAcroForm vector when only NeedAppearances is present. The condition `acro_dict.has(b"AA")
src/quarantine/mod.rs Added write_clean_report() — writes a JSON + Markdown "clean bill of health" report to the quarantine directory when the scan produces zero malicious findings. No tarball is written (nothing to quarantine), no payloads are carved (no malicious bytes), no cleansed file is produced (nothing to cleanse). Same report_<timestamp>_<sha_prefix>.{json,md} filename convention as the malicious case.
src/core/pipeline.rs Modified step 4 to call write_clean_report() when malicious_count() == 0. Added step 5 path: when the scan is clean AND emit_clean_output is true (default), the original file is copied to cleanse_dir under the name clean_<timestamp>_<sha_prefix>.<ext>. When move_clean_to_output is true, the source file is removed after the copy succeeds (best-effort — failed unlink doesn't fail the pipeline). Added emit_clean_output() helper function.
src/main.rs Added two new CLI flags: --no-clean-output (disable the clean-output copy) and --move-clean (move instead of copy). Added env-var overrides CORBEL_EMIT_CLEAN_OUTPUT and CORBEL_MOVE_CLEAN_TO_OUTPUT to apply_env_overrides(). Updated help text.
tests/pipeline_integration.rs Updated benign_pdf_produces_no_findings and benign_realworld_pdf_produces_zero_findings to assert the new clean-output behavior — the clean copy exists, has the right name, and is a byte-for-byte copy of the original.
scripts/gen_benign_realworld_pdf.py New fixture generator. Produces a 3-page PDF with 13 hyperlinks covering every previously-false-positive URL pattern, plus prose mentioning wget, exploit, payload, /bin/sh, PowerShell.
tests/fixtures/benign_realworld.pdf New fixture, generated by the script above.
FIX-NOTES-false-positive-redesign.md This file.

The corpus regression test

benign_realworld.pdf_produces_zero_findings is the lock-in. The fixture is a 3-page PDF containing:

  • https://www.linuxfromscratch.org/
  • https://lists.linuxfromscratch.org/listinfo/lfs-support (keyword "support" in path)
  • http://ftp.osuosl.org/pub/lfs/ (http: scheme)
  • https://github.com/LFS-project/build-scripts (github.com host)
  • mailto:lfs-support@linuxfromscratch.org (mailto: + @ in path)
  • https://www.kernel.org/pub/linux/kernel/
  • tel:+1-555-123-4567 (tel: scheme)
  • https://ftp.ru.debian.org/debian/ (.ru TLD)
  • https://en.wikipedia.org/wiki/Microsoft_Windows (brand in path)
  • https://github.com/microsoft/vscode (brand in path)
  • https://www.ietf.org/rfc/rfc2616.txt
  • https://example.com/account/verify (keywords in path)
  • https://example.com/login (keyword in path)

Plus prose containing wget, exploit, payload, /bin/sh, PowerShell.

The assertion is the theorem: the scanner produces ZERO findings on this document. Not "fewer than N", not "0 malicious but maybe some suspicious" — literally zero findings. If any finding appears, the detector that produced it is wrong by construction, not the document.

This test holds for every clean technical PDF — Linux from Scratch, an RFC, an O'Reilly chapter, an IRS form, a paper from arXiv, a vendor whitepaper, a WHO fact sheet — because none of them contain /JavaScript actions or homograph hosts. The "clean document produces zero findings" property is now a theorem about the detectors, not an empirical observation about one specific file.

Test results

Before the redesign (baseline from the uploaded tarball):

  • cargo test --lib → 137 passed, 0 failed
  • cargo test --test pipeline_integration → 23 passed, 0 failed
  • cargo test --test zip_bomb_defense → 4 passed, 0 failed

After the redesign:

  • cargo test --lib → 154 passed, 0 failed (+17 new detector + anti-false-positive tests)
  • cargo test --test pipeline_integration → 24 passed, 0 failed (+1 corpus regression test)
  • cargo test --test zip_bomb_defense → 4 passed, 0 failed (unchanged)

All pre-existing tests continue to pass, including the suite that exercises real PDF / DOCX / EPUB / Markdown fixtures in tests/fixtures/.

Known limitations / non-goals

  1. Reputation-feed integration (Category 3) is not yet wired up as a runtime URL lookup. The infrastructure for loading external homograph-host-list / signature-list / shellcode-list feeds exists, but no live URLhaus / PhishTank / OpenPhish client is included. Operators who want reputation-based detection can extend load_external_rules to fetch from a remote feed and reload periodically.

  2. Display-text / URL-host mismatch detection (Category 2) is not yet implemented. The infrastructure is in place — the parser emits the visible text and the URL as separate fields — but the heuristics do not currently compare them. This is a follow-up: a hyperlink whose visible text reads "microsoft.com" but whose href is https://evil.example.com/ is a verifiable impersonation and should be flagged.

  3. Path-traversal in PdfGoToR destinations is not inspected. The parser does not capture the /F (file reference) entry, so we can't distinguish GoToR to companion.pdf (benign) from GoToR to ../../etc/passwd (hostile). Capturing the destination would let the scanner apply a path-traversal detector — a verifiable property — and only then flag GoToR.

  4. Windows file paths like C:\path\to\file.txt will be parsed by extract_uri_scheme as having scheme "C" (because C is a valid RFC 3986 scheme character), and the heuristics will flag it as Malicious because "C" isn't in allowed_uri_schemes. This is an acceptable false positive for an unusual input — Windows file paths should be encoded as file:///C:/path/to/file in URIs.

  5. emit_suspicious is retained for API compatibility but no default detector produces Suspicious findings. Future detectors that produce genuinely indeterminate signals (e.g. an unrecognized embedded-file format that has structural indicators of active content but no matching magic bytes) could use this tier.