Metadata-Version: 2.5
Name: xlsx-provenance
Version: 1.1.0
Summary: Fingerprint .xlsx files for authorship and authenticity: Excel vs openpyxl, xlsxwriter, Aspose, LibreOffice and others
Project-URL: Homepage, https://github.com/jtannahill/xlsx-provenance
Project-URL: Repository, https://github.com/jtannahill/xlsx-provenance
Project-URL: Issues, https://github.com/jtannahill/xlsx-provenance/issues
Author: James Tannahill
License-Expression: MIT
License-File: LICENSE
Keywords: excel,forensics,metadata,ooxml,openpyxl,provenance,xlsx
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
Classifier: Topic :: Security
Classifier: Topic :: Utilities
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# xlsx-provenance

![Status](https://img.shields.io/badge/status-active-success)
![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=white)
![License](https://img.shields.io/github/license/jtannahill/xlsx-provenance)
![Last Commit](https://img.shields.io/github/last-commit/jtannahill/xlsx-provenance)

Fingerprint `.xlsx` files for authorship and authenticity. Tells you whether a workbook was actually authored in Excel, or generated by a library (`openpyxl`, `xlsxwriter`, `Aspose`, `ClosedXML`, `EPPlus`, `SheetJS`, ...) or another office suite (`LibreOffice`, `OnlyOffice`, `WPS`, ...).

Stdlib-only Python. No third-party dependencies.

## Why

An `.xlsx` is a zip of XML. Every authoring tool leaves fingerprints: different `Application` strings, different `fileVersion` attributes, different presence/absence of `calcChain.xml`, `theme1.xml`, `printerSettings*.bin`, `<HeadingPairs>`, `<TitlesOfParts>`, `cellXfs count=""` attributes, and so on.

This tool reads those fingerprints and gives you a per-file verdict you can act on.

## Install

```bash
pipx install xlsx-provenance        # or: uv tool install xlsx-provenance
brew install jtannahill/tap/xlsx-provenance
```

Or run straight from a checkout (no install, stdlib only):

```bash
git clone https://github.com/jtannahill/xlsx-provenance.git
./xlsx-provenance/xlsx-provenance some-file.xlsx
```

## Editing metadata

The same tool can rewrite the declared metadata, either to scrub a file before
sharing it or to set the properties you want.

```bash
xlsx-provenance --strip report.xlsx                      # blank author, company, dates, application... in place
xlsx-provenance --strip --backup report.xlsx             # same, keeping report.xlsx.bak
xlsx-provenance --strip -o clean.xlsx report.xlsx        # write to a new file
xlsx-provenance --set creator="Jane Doe" --set company=Acme --set created=2024-01-02 report.xlsx
xlsx-provenance --strip --set creator="Jane Doe" report.xlsx   # scrub, then set just one
xlsx-provenance --list-properties                        # keys accepted by --set
```

Only `docProps/core.xml` and `docProps/app.xml` are rewritten. Every other zip
member is copied byte for byte in its original order, so the workbook stays
exactly as valid as it was and the structural provenance signals are untouched.
An empty value (`--set manager=`) removes a property; dates take ISO 8601 and
are stored as UTC.

This edits *declared* metadata. It is not a way to pass a generated file off as
Excel-authored: the analyzer requires the workbook body to corroborate a
declared Excel application (fileVersion, theme, styles, calcChain...), and a
file that claims Excel without that structure is reported as `SUSPECT`. A
stripped file is reported with a `metadata=stripped` signal.

## Usage

```bash
xlsx-provenance file1.xlsx [file2.xlsx ...]
xlsx-provenance *.xlsx
xlsx-provenance --json file.xlsx           # machine-readable
xlsx-provenance -v file.xlsx               # show full signal breakdown
xlsx-provenance -q *.xlsx                  # one line per file
xlsx-provenance --no-color file.xlsx       # plain output
```

Exit code is `0` if every file is verdict `EXCEL_*` or a non-Excel office suite (`LIBREOFFICE`, `ONLYOFFICE`, etc.). It is `1` if any file came back from a programmatic library (`OPENPYXL`, `XLSXWRITER`, ...) or as `SUSPECT` / `UNKNOWN` / `MISSING` / `INVALID`. Useful in CI.

## Verdicts

| Verdict | Meaning |
|---|---|
| `EXCEL_MAC` | `Microsoft Macintosh Excel`. Authentic Excel for Mac |
| `EXCEL_WIN` | `Microsoft Excel`. Authentic Excel for Windows or Online |
| `EXCEL_OTHER` | Some other Excel variant string, but Excel `fileVersion` confirmed |
| `EXCEL_LIKELY` | No explicit Application, but enough Excel-only artifacts (calcChain, fileVersion appName=xl, real theme, printerSettings, VBA, threaded comments, etc.) to be confident |
| `OPENPYXL`, `XLSXWRITER`, `ASPOSE`, `CLOSEDXML`, `EPPLUS`, `OPENXML_SDK`, `SHEETJS`, `SPREADJS`, `SYNCFUSION`, `GEMBOX`, `SPIRE`, `LUCKYSHEET`, `PYTHON_XLSX` | Application string explicitly declared a generation library |
| `LIBREOFFICE`, `ONLYOFFICE`, `OPENOFFICE`, `GNUMERIC`, `CALLIGRA`, `WPS_OFFICE`, `APPLE_NUMBERS`, `GOOGLE_SHEETS` | Authentic but non-Excel office suite |
| `SUSPECT` | No `Application` and no `fileVersion`. Looks tampered or hand-built |
| `UNKNOWN` | Couldn't classify |
| `MISSING` | File doesn't exist |
| `INVALID` | Not a valid zip / corrupted |

## Signals examined

- **`docProps/app.xml`**: `Application`, `AppVersion`, `Company`, `Manager`, `DocSecurity`, presence of `HeadingPairs` + `TitlesOfParts` (Excel-only, openpyxl skips)
- **`docProps/core.xml`**: `creator`, `lastModifiedBy`, `created` / `modified` timestamps (and their delta; < 1s smells automated), `lastPrinted`
- **`xl/workbook.xml`**: `<fileVersion appName="xl" rupBuild="...">` (Excel-only), `workbookPr/@codeName`, defined names, sheet count
- **Zip artifacts**: `calcChain.xml` (Excel writes, libraries usually skip), `theme/theme1.xml` size (Excel: ~6796–8390 B; openpyxl: < 4 KB), `printerSettings*.bin`, `vbaProject.bin`, `pivotTables/`, `pivotCache/`, `connections.xml`, `externalLinks/`, `charts/`, `drawings/`, `comments*.xml`, `threadedComments` (Excel 365), `tables/`, `queryTables/`
- **`xl/styles.xml`**: Excel often *omits* `count="N"` on `<cellXfs>`; openpyxl always includes it. Excel writes `<tableStyles>`, `<indexedColors>`.
- **`xl/sharedStrings.xml`**: `uniqueCount` attribute presence
- **`[Content_Types].xml`**: number of overrides, presence of theme override

The verdict combines a hard match on the `Application` string with a soft score (0–14) over the structural signals. High score with no library declaration → `EXCEL_LIKELY`.

## Examples

Pretty (default):

```
=== model.xlsx ===
  [EXCEL_MAC] Excel signal score 11/14  (confidence: high)
  application    Microsoft Macintosh Excel  /  AppVersion 16.0300
  identity       creator=''  lastModifiedBy='james tannahill'
  fileVersion    appName='xl' lastEdited='7' lowestEdited='7' rupBuild='10503'
  workbook       sheets=11  definedNames=0  codeName=None
  artifacts      calcChain, theme1(6798B), printerSettings, drawings
  styles.xml     count attr omitted, tableStyles, indexedColors
  content-types  18 overrides, theme=True
  zip            22 entries
```

Library tell:

```
=== generated.xlsx ===
  [OPENPYXL] Application explicitly declares openpyxl  (confidence: high)
  application    Microsoft Excel Compatible / Openpyxl 3.1.5  /  AppVersion 3.1
  identity       creator=''  lastModifiedBy='someone'
  fileVersion    <missing>
  ...
```

JSON for piping into other tooling:

```bash
xlsx-provenance --json *.xlsx | jq '.[] | select(.verdict == "OPENPYXL") | .path'
```

## How to "fix" an openpyxl-generated file

Open it in real Excel and `File → Save As` (overwrite or new name). Excel rewrites every metadata field (`Application`, `fileVersion`, `calcChain`, theme) to its native fingerprint. Editing `docProps/app.xml` by hand only fixes the visible Application string and leaves the deeper structural tells intact.

## License

MIT
