Skip to main content

unmasker

What a human sees in a document, against what a machine reads out of it.

Local, read-only detection of hidden, residual and failed-redaction content in documents.

PyPI Python 3.10+ 27 detectors Runtime dependencies Local and read-only Network requests CI License

Install · Usage · What it finds · Examples · Detector reference · Automation


unmasker finds content that survives inside a document but is hidden, covered, deleted, filtered, cropped or otherwise absent from what a person normally sees.

A black rectangle drawn over text is not a redaction. The page may look clean while the original text remains in the file and is still readable by a parser. unmasker reports that mismatch and stops there - evidence, not a verdict.

unmasker tests/specimens/pdf/libreoffice-writer-black-bars.pdf
  unmasker                                                      4 findings
    tests/specimens/pdf/libreoffice-writer-black-bars.pdf
  ────────────────────────────────────────────────────────────────────────

  ● 22 characters under a black shape at x 117.5-268.7, y           page 1
    684.2-698.5; the rest of the line still reads "Name:"
  │ human sees     ██████████████████████
  │ machine reads  Wanda Testowa-Przyklad

  ● 21 characters under a black shape at x 114.8-259.3, y           page 1
    664.5-678.7; the rest of the line still reads "Email:"
  │ human sees     █████████████████████
  │ machine reads  w.testowa@example.org

  ● 15 characters under a black shape at x 123.1-228.1, y           page 1
    644.7-658.9; the rest of the line still reads "Telephone:"
  │ human sees     ███████████████
  │ machine reads  +48 601 000 000

  ● 37 characters under a black shape at x 119.0-369.1, y           page 1
    624.9-639.2; the rest of the line still reads "Address:"
  │ human sees     █████████████████████████████████████
  │ machine reads  ul. Przykladowa 12/3, 00-001 Warszawa

  notes                                                             1 note
  ────────────────────────────────────────────────────────────────────────
    the file says it was made by Creator Writer; Producer LibreOffice
    24.2, and dates itself CreationDate 2026-08-31T20:25:50Z

  ────────────────────────────────────────────────────────────────────────
  searched 1 page of 1. 4 findings in 1 kind.
  sha256 fbaeef0d794f2bdb6f4c6bb823686a10d1e1b67e6b2d49df5fe33fd9a281f1a5

Every finding says what a human sees, what a machine reads, where the mismatch is and how the tool knows.

That file is a specimen committed to this repository, as is every other example on this page. Clone it and the commands run.

Installation

pipx install unmasker

Or:

uv tool install unmasker

Python 3.10 or later. The default install has one runtime dependency: pypdf. unmasker runs locally, makes no network requests and never modifies the file it is given. Two optional flags call other programs — --ocr needs ghostscript and tesseract, --render needs LibreOffice — and both are off unless asked for.

From a checkout:

git clone https://github.com/osint-shifu/unmasker
cd unmasker
python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/unmasker document.pdf

Usage

unmasker <file>   [options]
unmasker <folder> [options]

Scan one file:

unmasker document.pdf

Survey a folder:

unmasker ~/cases/kowalski

Produce machine-readable or shareable output:

unmasker document.pdf --json
unmasker document.pdf --md > report.md
unmasker ~/cases/kowalski --html > report.html
Option Purpose
--json one JSON object, for a script to sort or filter
--html one self-contained HTML report, ready to send to someone
--md Markdown for a wiki, a ticket or a pull request
--ocr render a page and read it back to catch mismatches without knowing the hiding technique
--render lay a word-processor document out with LibreOffice and look at what it paints
--width N wrap terminal output at N columns
--version print the version and exit
-h, --help show the full option list

Exit status is part of the interface:

Code Meaning
0 read, searched, nothing found
1 read, searched, findings exist
2 could not be read

A file that could not be read is not a file that came back clean. unmasker keeps those states separate so a pipeline cannot silently treat failure as a clean result.

What it finds

Area Examples
PDF pages covered text, text under images, invisible text, low contrast, off-page content
PDF revisions pages and text left in the file by an incremental update, which the current document no longer points at
Unicode zero-width characters, bidi controls, tag characters, mixed scripts
Word / ODT tracked deletions, comments, revision history, metadata leaks
Excel / Calc hidden sheets, rows and columns, filtered rows, tracked cell changes
PowerPoint / Impress hidden slides, speaker notes, and a shape drawn over text on a slide
JPEG stale EXIF thumbnails that can preserve content removed by cropping, and the XMP edit history an editor left behind
Metadata undisclosed values, local filesystem paths and conflicting metadata copies
Legacy Word a .doc's text, story by story — body, footnotes, headers, footers, text boxes — with its comments, tracked changes and hidden runs
Legacy Office what a .xls or .ppt says about itself, out of its compound-file property streams
Attachments whole files carried inside a document, which no page mentions — and what a carried workbook hides in turn
OCR comparison text present in the file but absent from the rendered page, and the reverse

Supported inputs are PDF, DOCX, ODT, XLSX, ODS, PPTX, ODP, JPEG, UTF-8 text and legacy .doc, plus .xls and .ppt for their metadata. Content is identified from the file itself rather than trusted solely from the extension.

What runs on what

Not every check runs on every format, and a check that did not run cannot report anything. If a format is missing from a row, that question was never asked about your file — which is not the same answer as asking it and finding nothing.

What is checked Runs on
a filled shape or an image drawn over text PDF, PPTX, ODP
the rest of what the page paints: faint text, text the page does not paint, text off the page PDF
characters in the text: zero-width, direction controls, tag characters, mixed scripts PDF, DOCX, ODT, XLSX, ODS, PPTX, ODP, DOC, text
text the file marks as not to be drawn DOCX, ODT, DOC
hidden sheets, rows and columns, and filtered rows XLSX, ODS
hidden slides and speaker notes PPTX, ODP
the embedded thumbnail against the image it sits in JPEG
tracked changes and comments DOCX, ODT, DOC, XLSX, ODS
whole files carried inside the document PDF, DOCX, ODT, XLSX, ODS, PPTX, ODP
earlier revisions the file still holds PDF
metadata against the document's own text PDF, DOCX, ODT, XLSX, ODS, PPTX, ODP, DOC, XLS, PPT, JPEG

--render adds DOCX and ODT to the first row, by laying the document out with LibreOffice and looking at what it paints. It answers about that rendering rather than about the file, so what it finds is circumstantial and says so.

This table is checked against the readers, so it cannot drift away from what the code can actually see.

Practical examples

Text under an image

unmasker tests/specimens/pdf/libreoffice-writer-image-over-text.pdf
  unmasker                                                       1 finding
    tests/specimens/pdf/libreoffice-writer-image-over-text.pdf
  ────────────────────────────────────────────────────────────────────────

  ● 22 characters under an image at x 123.3-245.9, y 702.8-720.0;   page 1
    an image over a text layer is also what a scan of a printed
    page looks like, and there the two normally agree; the rest of
    the line still reads "Subject:"
  │ human sees     ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  │ machine reads  Ludmila Wieczorek-Test

  notes                                                             1 note
  ────────────────────────────────────────────────────────────────────────
    the file says it was made by Creator Writer; Producer LibreOffice
    24.2, and dates itself CreationDate 2026-08-31T23:50:46Z

  ────────────────────────────────────────────────────────────────────────
  searched 1 page of 1. 1 finding in 1 kind.
  sha256 d324105840b72c0d76c491150fe9220eabb4a87a3735afdb5de5af4c27fa0b66

An image over text is also what a scan of a printed page looks like. unmasker says so in the finding rather than calling it a redaction.

Hidden spreadsheet data

unmasker tests/specimens/xlsx/libreoffice-calc-hidden-columns.xlsx
  unmasker                                                      7 findings
    tests/specimens/xlsx/libreoffice-calc-hidden-columns.xlsx
  ────────────────────────────────────────────────────────────────────────

  comment                                                        1 finding
  ────────────────────────────────────────────────────────────────────────
  ● a comment by Halina Probna-Test, at a time the file does    whole file
    not state, carried in the file and not part of the
    document body
  │ human sees     nothing on the page
  │ machine reads  Panel agreed the reserve before the bids were opened.
  │                Not for the file we release.

  revision-history                                               1 finding
  ────────────────────────────────────────────────────────────────────────
  ● the file records 1 comment, by 1 person. A name here is     whole file
    whatever the application that wrote it was configured to
    say
  │ human sees     nothing on the page
  │ machine reads  Halina Probna-Test

  hidden-sheet                                                   1 finding
  ────────────────────────────────────────────────────────────────────────
  ● sheet "Workings" is marked hidden, and holds 1 value still  whole file
    in the file
  │ human sees     nothing on the page
  │ machine reads  Reserve set at 240,000. Kowalski came in 12% under; the
  │                others were told nothing.

  hidden-rows                                                    1 finding
  ────────────────────────────────────────────────────────────────────────
  ● row 4 of sheet "Evaluation" is hidden, and holds 6 values   whole file
    still in the file
  │ human sees     nothing on the page
  │ machine reads  Delta Consulting sp. z o.o. | 82 | 44 | 196000 | 63 |
  │                withdrawn after the deadline - do not list

  hidden-columns                                                 1 finding
  ────────────────────────────────────────────────────────────────────────
  ● column D of sheet "Evaluation" is hidden, and holds 5       whole file
    values still in the file
  │ human sees     nothing on the page
  │ machine reads  Reserve price (EUR) | 211000 | 238000 | 196000 | 251000

  undisclosed-metadata                                          2 findings
  ────────────────────────────────────────────────────────────────────────
  ● the creator field of docProps/core.xml holds a value the    whole file
    document does not show anywhere in its text
  │ human sees     nothing on the page
  │ machine reads  Halina Probna-Test

  ● the title field of docProps/core.xml holds a value the      whole file
    document does not show anywhere in its text
  │ human sees     nothing on the page
  │ machine reads  Tender evaluation - panel copy

  notes                                                             1 note
  ────────────────────────────────────────────────────────────────────────
    the file says it was made by Application
    LibreOffice/24.2.7.2$Linux_X86_64 LibreOffice_project/420$Build-2;
    AppVersion 15.0000, and counts revision 0; TotalTime 0

  ────────────────────────────────────────────────────────────────────────
  searched the text of this file. 7 findings in 6 kinds.
  sha256 6cb60d45c5e8fe7b2c05c1eee565ec417079909800f48209c88b28c42ec53c8e

Case-folder triage

unmasker tests/specimens/docx
  unmasker  tests/specimens/docx                             6 of 12 files
  ────────────────────────────────────────────────────────────────────────
    read      12 files, 20 findings
    not read  0 files

  what was found                                                  12 kinds
  ────────────────────────────────────────────────────────────────────────
    undisclosed-metadata  3 files
    attached-file         1 file
    hidden-sheet          1 file
    zero-width            1 file
    bidi-control          1 file
    tag-characters        1 file
    mixed-script          1 file
    invisible-text        1 file
    metadata-path         1 file
    deleted-text          1 file
    comment               1 file
    revision-history      1 file

  files that hide something                                        6 files
  ────────────────────────────────────────────────────────────────────────
    libreoffice-writer-covered-text.docx       undisclosed-metadata
    libreoffice-writer-embedded-sheet.docx     attached-file, hidden-sheet
    libreoffice-writer-hidden-characters.docx  zero-width, bidi-control,
                                               tag-characters,
                                               mixed-script
    libreoffice-writer-hidden-run.docx         invisible-text,
                                               undisclosed-metadata
    libreoffice-writer-metadata-leak.docx      undisclosed-metadata,
                                               metadata-path
    libreoffice-writer-tracked-changes.docx    deleted-text, comment,
                                               revision-history

  ────────────────────────────────────────────────────────────────────────
  searched 12 files. unmasker <file> for the detail, --json for all of it.

A directory scan reports what was read, what could not be read, which detector kinds appeared and which files contain findings. It does not rank files by "severity". Use the detailed file report or --json for every finding.

Read the rendered page back

unmasker scan.pdf --ocr

--ocr asks a different question: does the text stored in the document agree with the page after it is rendered? This can expose a hiding technique for which there is no dedicated detector yet.

For PDF page comparison it requires ghostscript and tesseract on PATH and costs seconds per page, so it is intentionally opt-in and refused for directory surveys.

Detector reference

The detector slug is stable output intended for reports and automation.

Page and rendering

Detector What it reports
covered-text text underneath a filled shape — per character in a PDF, per text box on a slide
text-under-image text underneath an image, kept distinct from a filled-shape redaction
invisible-text text the file tells the application not to draw — a PDF render mode that paints nothing, or a Word run marked hidden
low-contrast-text text too close in colour to the background behind it
off-page-text text outside the visible page or crop box
unrendered-text words stored in the file that OCR cannot find on the rendered page
unextractable-text words visible to OCR on the rendered page but absent from the extracted text

Characters

Detector What it reports
zero-width zero-width spaces, joiners, soft hyphens and word joiners
bidi-control Unicode direction controls that can change how a string appears on screen
tag-characters plane-14 Unicode tag characters, decoded when possible
mixed-script a single word containing characters from multiple scripts

Spreadsheets

Detector What it reports
hidden-sheet a hidden or very-hidden sheet that still carries values
hidden-rows hidden rows that still carry values, collapsed into blocks
hidden-columns hidden columns that still carry values
filtered-rows rows currently excluded by a worksheet filter
changed-cell a tracked cell change, including the replaced value when available

Presentations

Detector What it reports
hidden-slide a slide stored in the deck but skipped when the presentation is shown
speaker-notes speaker-note text that never appears on the projected slide

Images

Detector What it reports
stale-thumbnail an embedded EXIF preview whose dimensions show it was not regenerated from the current image

Container, revisions and metadata

Detector What it reports
deleted-text text preserved inside a tracked deletion
comment comments and annotations stored with the document
revision-history document revision authorship and edit history
undisclosed-metadata metadata values that do not appear in the document's visible text
metadata-path filesystem paths exposed by document metadata
metadata-conflict contradictory copies of metadata stored inside the same file
attached-file a whole file carried inside the document, on no page and not printed with it
earlier-revision text an earlier revision of the file still holds, which no page shows now

Evidence model

unmasker reports observable mismatches. It does not decide whether a document is malicious, manipulated or safe.

Each finding carries one of three evidence bases:

  • direct - the relevant bytes are in the file and were read out directly
  • circumstantial - the observation is consistent with hiding, but an innocent explanation can also fit
  • self-reported - the document or application reports the fact about itself

There are no severity scores or synthetic confidence percentages. Different questions are not ranked against each other.

"Nothing found" and "nothing could be searched" are different results. unmasker keeps them separate. JSON exposes this explicitly through the searched field.

Values are not shortened with ellipses. If evidence is long, the report wraps it instead of silently dropping part of it.

Reports

The terminal report is for triage. Three stdout formats are available when the result needs to be stored, processed or sent to someone else:

  • --json for automation
  • --html for a self-contained human-readable report
  • --md for Markdown-based systems

unmasker treats report content as untrusted because every quoted value came from a file being investigated. HTML values are escaped, Markdown evidence is fenced or escaped, and generated HTML contains no JavaScript or external resources.

The tool has no --out option. Redirection keeps the core rule simple: unmasker itself never writes to the input or to the case directory.

JSON and automation

--json writes one object on stdout while the exit status remains usable as a pipeline gate:

unmasker document.pdf --json > findings.json || echo "findings exist"

The JSON shape is versioned separately from the package version:

Field Meaning
schema unmasker.scan/1 for one file or unmasker.survey/1 for a folder
version the unmasker build that produced the report
searched false means there was nothing to search, not that a search came back empty
sha256 the digest of the bytes that were read, so a finding can be checked against the file rather than the path
location.inside present when a finding came out of a file the document carries, naming it
unmasker tests/specimens/pdf/libreoffice-writer-image-over-text.pdf --json
{
  "tool": "unmasker",
  "schema": "unmasker.scan/1",
  "version": "0.5.0",
  "file": "tests/specimens/pdf/libreoffice-writer-image-over-text.pdf",
  "sha256": "d324105840b72c0d76c491150fe9220eabb4a87a3735afdb5de5af4c27fa0b66",
  "kind": "pdf",
  "searched": true,
  "remarks": [
    "the file says it was made by Creator Writer; Producer LibreOffice 24.2, and dates itself CreationDate 2026-08-31T23:50:46Z"
  ],
  "findings": [
    {
      "detector": "text-under-image",
      "basis": "direct",
      "summary": "22 characters under an image at x 123.3-245.9, y 702.8-720.0; an image over a text layer is also what a scan of a printed page looks like, and there the two normally agree; the rest of the line still reads \"Subject:\"",
      "human_sees": "▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒",
      "machine_reads": "Ludmila Wieczorek-Test",
      "location": {
        "page": 1
      },
      "codepoints": [
        "U+004C",
        "U+0075",
        "U+0064",
        "U+006D",
        "U+0069",
        "U+006C",
        "U+0061",
        "U+0020",
        "U+0057",
        "U+0069",
        "U+0065",
        "U+0063",
        "U+007A",
        "U+006F",
        "U+0072",
        "U+0065",
        "U+006B",
        "U+002D",
        "U+0054",
        "U+0065",
        "U+0073",
        "U+0074"
      ]
    }
  ]
}

The /1 changes only when the JSON shape changes incompatibly. A normal package release does not force downstream consumers to guess whether their parser still works.

Design principles

  • Evidence, not verdicts. Report what can be shown and leave interpretation to the analyst.
  • Absence is explicit. Unreadable, unsearched and searched-with-no-findings are different states.
  • Read-only. Input files are never modified.
  • Local. No uploads and no network requests.
  • Real specimens. Detectors are tested against files produced by real applications, not only hand-built fixtures.
  • No hidden ranking. Findings are grouped by what was observed, not by an invented severity score.

What unmasker is not

unmasker is not a malware scanner, DLP system, authenticity classifier or forensic verdict engine. It exposes discrepancies and residual content so a human or a downstream system can decide what they mean.

How it is tested

Every detector fires on a committed specimen written by a real producer, including LibreOffice, headless Chrome, Ghostscript, Tesseract, exiftool, ImageMagick, poppler and pypdf. There are 48 of them and each has a provenance note describing how it was produced, what a person sees and what is actually stored inside.

This matters because real producers routinely disagree with assumptions made from a file-format specification. The first PDF specimen, for example, showed that LibreOffice represented a redaction bar differently from the shape the initial detector design expected.

Independent tooling such as Poppler is used where specimens need measurements, and detector behavior is mutation-tested so a broken rule has to break a test. The README is checked against the repository so the front page cannot silently drift away from the code: the detector list, the detector badge, the specimen count, and every example on this page - each command is run and its output compared to the block printed beneath it.

Limits

  • No verdicts about whether a document was manipulated or malicious.
  • No writing to the input file and no network access.
  • What a page paints is read straight from the file for PDF, and computed from the file for .pptx and .odp, where every shape carries absolute coordinates against a known slide size.
  • A word processor states neither. Its text flows, so a .docx or .odt says there is a filled shape and says there is text and never says one is over the other. --render answers it by laying the document out with LibreOffice and looking at what got painted — which means handing the file to another program, and answering about that rendering rather than about the file. Word lays a page out differently, so a bar that covers a name there may miss it here. Every finding from it is circumstantial and names the rendering it came from.
  • A .doc is read for its text as well as its metadata: the piece table, every story, and the property tables that say which characters are deleted or hidden. Its embedded objects are not opened, and character formatting other than the hidden attribute is not read.
  • .xls and .ppt are read for what they say about themselves, not for their text. The compound-file container and both property streams are read; the BIFF and PowerPoint record streams inside them are not.
  • Signature coverage is not checked. A signed PDF's /ByteRange says which bytes it covers, and no signed specimen could be produced to test a detector against.
  • XMP is read in PDF and JPEG. DOCX and TIFF carry packets too and are not read yet.
  • Producer coverage is not universal. Microsoft Word and Adobe Acrobat are not part of the current specimen corpus, and different producers can encode the same feature differently.
  • OCR is optional, slower than structural detection and depends on external tools.

See tests/specimens/README.md for the current producer coverage and explicitly documented gaps.

Development

git clone https://github.com/osint-shifu/unmasker
cd unmasker
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest
.venv/bin/ruff check .
.venv/bin/mypy

Project references:

License

Apache License 2.0.


unmasker

What a human sees in a document, against what a machine reads out of it.

Made by osint-shifu

Metadata

Release files for unmasker 0.5.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 unmasker 0.5.0
File Size Uploaded
unmasker-0.5.0.tar.gz 881.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for unmasker 0.5.0
File Interpreter ABI Platform
unmasker-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.1 MB

Release files / unmasker-0.5.0.tar.gz

Download URL unmasker-0.5.0.tar.gz
Size 881.2 kB
Tags Source
SHA-256 checksum
How to use checksums
b13c46af41ebcf528b669f7663997ae6273233e0642d6be93805a75917d03e16
BLAKE2b-256 checksum
How to use checksums
c3ecbe7515c8a1b38305e529539ef2234fd6b76e678627808c82d1b76561e1c3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.

Transparency log

Release files / unmasker-0.5.0-py3-none-any.whl

Download URL unmasker-0.5.0-py3-none-any.whl
Size 193.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
209c0c51022195e6b0cce4250553272b955f522a9fb4fa5ae1a5d29077b228f5
BLAKE2b-256 checksum
How to use checksums
e8808f808ed6f0027ed862a28f091061ef95c1bd870acafd765ec6f8ed10dfcb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 12, 2026.

Transparency log

Release history Release notifications | RSS feed

0.7.0

2 release files

0.6.0

2 release files

0.5.1

2 release files

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

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