@patrickdk77/office-defuse 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 patrickdk77
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,231 @@
1
- # Temporary Holding Version
1
+ # office-defuse
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ office-defuse reads Word, Excel, PowerPoint, Visio and OpenDocument packages and reports VBA and Excel 4.0 macros, LibreOffice Basic and scripts, DDE, fields and events that run code, templates and objects loaded from outside the file, embedded OLE objects, ActiveX controls and unsafe links. It scores the risk from 0 to 100.
4
+
5
+ This version inspects and changes nothing.
6
+
7
+ It has no runtime dependencies and keeps memory bounded. It reads the ZIP and XML containers strictly, and refuses anything two readers could take in different ways.
8
+
9
+ ```
10
+ npm install @patrickdk77/office-defuse
11
+ ```
12
+
13
+ Node 22 or later.
14
+
15
+ ## Quick start
16
+
17
+ ```js
18
+ const { inspectOffice } = require('@patrickdk77/office-defuse');
19
+
20
+ const report = await inspectOffice(bytes);
21
+ report.status; // 'clean', 'strippable' or 'rejected'
22
+ report.risk; // 'NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL' or 'UNKNOWN'
23
+ report.score; // 0 to 100, null when the file could not be inspected
24
+ report.format; // 'docx', 'xlsm', 'odt', ... or '' when not recognized
25
+ report.mimeType; // the MIME type of that format, or ''
26
+ report.findings; // [{ category, detail, description, action, location, data }]
27
+ ```
28
+
29
+ ES modules work too: `import { inspectOffice } from '@patrickdk77/office-defuse'`.
30
+
31
+ `strippable` means at least one finding has the action `strip`, content a defused copy would leave out. `rejected` means a finding has the action `reject`, or the run stopped before it read the whole package.
32
+
33
+ ### Files and S3
34
+
35
+ `inspectOfficeSource` reads through a source with `size()` and `read(offset, length)`, so a local file or an S3 object never has to sit in memory. `fileSource` and `bufferSource` are included. For S3, `read` can be a ranged GetObject.
36
+
37
+ ```js
38
+ const { inspectOfficeSource, fileSource } = require('@patrickdk77/office-defuse');
39
+
40
+ await inspectOfficeSource(fileSource('upload.docx'));
41
+
42
+ const source = {
43
+ size: async () => contentLength,
44
+ read: async (offset, length) => getObjectRange(bucket, key, offset, offset + length - 1),
45
+ };
46
+ await inspectOfficeSource(source);
47
+ ```
48
+
49
+ `inspectOfficeSource` calls the source's `close()`, if it has one, when the run ends. A `read` must return exactly the bytes asked for, or fewer only at the end of the source. Any other length fails the run with an error whose `code` is `EIO`. An error the source throws comes back unchanged, instead of a verdict on the file.
50
+
51
+ ## Formats
52
+
53
+ The format comes from the content, never from the file name.
54
+
55
+ | Family | Formats |
56
+ |---|---|
57
+ | Word | .docx .docm .dotx .dotm |
58
+ | Excel | .xlsx .xlsm .xltx .xltm .xlam .xlsb |
59
+ | PowerPoint | .pptx .pptm .potx .potm .ppsx .ppsm .ppam .sldx .sldm |
60
+ | Visio | .vsdx .vsdm .vstx .vstm .vssx .vssm |
61
+ | OpenDocument | .odt .ott .ods .ots .odp .otp .odg .otg .odf .odm .odb |
62
+
63
+ `[Content_Types].xml` marks an Office Open XML package. The package relationship of type `officeDocument`, transitional or strict, or Visio's `document`, names the main part, and its content type gives the format. The `mimetype` entry marks an OpenDocument package, or the manifest's entry for `/` when there is no mimetype entry.
64
+
65
+ A ZIP with both markers is rejected as PACKAGE/AMBIGUOUS_FORMAT, since Office could read one and LibreOffice the other. So is an OOXML package with two main parts, and an ODF package whose mimetype and manifest name different formats. Anything else is PACKAGE/UNRECOGNIZED.
66
+
67
+ A .xlsb gets the package checks. Its sheets are binary records, and the formulas in them go unread. Its external link parts are binary too, and office-defuse reads their link records for DDE links. A .odb gets every ODF check, and the database under `database/` goes unread.
68
+
69
+ ## What it reports
70
+
71
+ - VBA projects, Excel 4.0 macro sheets, and OpenDocument `Basic/`, `Scripts/` and `Dialogs/` folders. These go by content type, relationship type or folder name, and a folder counts when it holds a file that is not empty. office-defuse parses nothing inside an OLE file such as `vbaProject.bin`.
72
+ - OpenDocument script event listeners that name a script URL or macro. Those that fire as the document opens or is created get a finding of their own. LibreOffice ignores a listener whose URL and macro name are both empty, and so does office-defuse. Visio ShapeSheet cells that call RUNADDON, RUNADDONWARGS, CALLTHIS, RUNMACRO, OPENFILE or DOOLEVERB.
73
+ - Links and actions that run macros: `vnd.sun.star.script:` and `macro:` targets, PowerPoint's `ppaction://macro`, Excel drawing shapes with a `macro` attribute, and form controls with an `x:FmlaMacro`. Ribbon callbacks such as `onLoad` and `onAction`. OpenDocument menu, toolbar and key settings under `Configurations2/`. LibreOffice writes an empty `Configurations2/accelerator/current.xml` into the documents it saves, and an empty file counts for nothing.
74
+ - PowerPoint and Impress actions that run a program or an object verb.
75
+ - DDE: Word `DDE` and `DDEAUTO` fields, Excel `ddeLink` parts and the .xlsb record for them, Excel formulas in `application|topic!item` form, OpenDocument DDE connections and `DDE()` formulas. No amount of white space before a field's name hides it, and a formula is checked whole however long it runs.
76
+ - Word fields that run a macro, `MACROBUTTON`, and fields whose type another field builds. That is the QUOTE trick that hides `DDEAUTO`.
77
+ - References that load from outside the file on their own: attached and OpenDocument templates, linked OLE objects and packages, images, frames, included files and linked sections, external workbooks, `meta:auto-reload`, and any other external relationship. A target on the machine itself is EXTERNAL/LOCAL_FILE. An empty OpenDocument reference is the document itself and names no file, so it goes unreported. A target counts as local only if its percent-decoded form is local too, because a CVE-2017-0199 sample writes its `script:` scheme as `%73cript:`.
78
+ - Hyperlinks, with pdf-defuse's checks of the target: the scheme, file and network paths, data URLs, credentials before the host, IP hosts, punycode and mixed-script hosts, and percent-encoded hosts. A link to a place inside the document goes unreported, and so does an empty link, which is the document itself.
79
+ - Embedded OLE objects and packages, OLE files nothing declares as an object, Java applets, plug-ins and ActiveX controls.
80
+ - Package structure: parts without a content type, parts no relationship reaches, files the ODF manifest does not list, an irregular ODF layout, and names a package gives twice.
81
+ - Document type declarations in XML parts, and references to external entities. See [Document types and entities](#document-types-and-entities).
82
+
83
+ The OOXML rules match elements by namespace URI and local name, compared ignoring case, so a prefix bound to the Word namespace reads as Word whatever it is called. The OpenDocument rules read `urn:oasis:names:tc:<any>:xmlns:<name>:1.<any>` as version 1.0 of that namespace, which is what LibreOffice does. Every branch of `mc:AlternateContent` gets read.
84
+
85
+ ## The containers
86
+
87
+ The ZIP reader starts from the end record and the central directory. It checks each local header against the central directory before it reads any content. It refuses these, and stops the run:
88
+
89
+ | Detail | Refused |
90
+ |---|---|
91
+ | NOT_ZIP | No end record in the last 65,557 bytes, or one whose comment runs past the end of the file |
92
+ | AMBIGUOUS_END | An end record whose comment holds a second end signature |
93
+ | MULTI_DISK | Any disk number other than 0 |
94
+ | DIRECTORY_MISMATCH | An entry count or directory size that disagrees with the central directory, or a central directory that does not end where the end record begins. Data prepended without fixing the offsets lands here |
95
+ | OUT_OF_RANGE | A central directory or local header outside the file or its entries, or a 64-bit value larger than a JavaScript number holds exactly |
96
+ | OVERLAP | Two entries that share bytes, or an entry that runs into the central directory |
97
+ | HEADER_MISMATCH | A local header that is missing, or whose name, method, encryption, data descriptor or UTF-8 flags, CRC or sizes differ from the central directory, and a data descriptor that differs |
98
+ | DUPLICATE_NAME | Two names equal after percent-decoding, Unicode NFC and lowercasing |
99
+ | UNSAFE_NAME | An empty name, a name over 1024 bytes, a leading `/`, a drive letter, a backslash, a control character, an empty, `.` or `..` segment, or a directory entry with content |
100
+ | NAME_ENCODING | A non-ASCII name without the UTF-8 flag, invalid UTF-8, or an Info-ZIP Unicode path field that names another entry |
101
+ | ENCRYPTED_ENTRY | Any of the encryption flags |
102
+ | UNSUPPORTED_METHOD | Any compression method but stored and deflate |
103
+ | ZIP64_MISMATCH | A field at its maximum with no ZIP64 value behind it, a ZIP64 extra field that holds other fields than its header defers to it, a local ZIP64 field that disagrees with its header, an end record that disagrees with the ZIP64 end record, or a locator that does not point at one |
104
+ | BAD_EXTRA_FIELD | An extra field block that does not split exactly into records, or a record id given twice |
105
+ | DATA_ERROR | Data that does not inflate to its declared size and CRC, deflate data that is corrupt, ends early or leaves compressed bytes unused, or a stored entry with two different sizes |
106
+
107
+ It reports three more without stopping. LEADING_BYTES is data before the first entry. HIDDEN_DATA is bytes between entries, or before the central directory, that no entry covers. TRAILING_BYTES is bytes after the end record that its comment does not cover. Each is `strip`.
108
+
109
+ The reader inflates every entry once, start to end, and checks it against its CRC and declared size. Inflation stops as soon as the output passes the declared size.
110
+
111
+ A writer that streams an entry sets the data descriptor flag and may leave the local header's CRC and sizes unset, as zero, or as ones in a local ZIP64 field. The data descriptor after the data then gives them, and they must match the central directory.
112
+
113
+ The XML tokenizer reads each part in pieces. It refuses these, and stops the run:
114
+
115
+ - XML/ENCODING: anything but UTF-8 or UTF-16, the two encodings OPC allows, an encoding declaration that disagrees with the bytes, and bytes that are not valid in their encoding.
116
+ - XML/MALFORMED: markup that breaks XML 1.0 or its namespaces. That covers an undeclared prefix, a duplicate attribute, a mismatched end tag, an element or text outside the root, a malformed name, reference or declaration, and characters XML 1.0 forbids. It also covers what XML 1.0 refuses about entities: a reference to an entity nothing declares, a reference to an external entity in an attribute value, entity text that refers to itself or leaves an element open, and a markup declaration outside the document type declaration.
117
+
118
+ References get expanded and line ends normalized as the XML specification says, so `DD&#69;AUTO` reads as `DDEAUTO`.
119
+
120
+ The `Basic/`, `Scripts/`, `Dialogs/` and `Configurations2/` folders of an OpenDocument package are reported by name, as a whole. The files in them are inflated and checked, not parsed. In an OOXML package, the parts read as XML are `[Content_Types].xml`, every `.rels` part, and every part whose content type ends in `+xml`, is `application/xml` or `text/xml`, or is the VML drawing type. In an OpenDocument package they are the other entries whose names end in `.xml`.
121
+
122
+ PowerPoint and Excel write VML drawings that are not well-formed XML. They put `<![if gte mso 9]>` and `<![endif]>` around content, and Excel 2007 leaves `<br>` unclosed in a button's text. office-defuse reads a part of the VML drawing type in that dialect. It skips the markers, so the content on both sides of them reaches the rules, and it takes `<br>` as an empty element. Apart from those two, it reads the part as strictly as any other.
123
+
124
+ ### Document types and entities
125
+
126
+ office-defuse never loads an external DTD and never fetches an external entity. A document type declaration does not stop the run.
127
+
128
+ | Finding | Action | Weight | What it is |
129
+ |---|---|---|---|
130
+ | XML/DOCTYPE | `info` | 0 | A declaration with a name and maybe an external identifier, and no internal subset. OpenOffice.org 2.x and StarOffice 8 wrote one into their manifests, and OpenOffice.org 2.x and 3.x into their formula objects |
131
+ | XML/INTERNAL_SUBSET | `strip` | 20 | A declaration with an internal subset, the declarations between square brackets, which can declare entities and attribute defaults |
132
+ | XML/EXTERNAL_ENTITY | `strip` | 40 | A reference to an external entity in element text |
133
+
134
+ The tokenizer expands internal entities where the part refers to them, in element text and in attribute values. The rules see the expanded text, so a relationship target, a hyperlink or a field code built from entities gets checked as its real value. Markup in entity text becomes elements.
135
+
136
+ A reference to an external entity in text stands for content office-defuse cannot read. Findings show it as `&name;`, and a rule that needs the value treats it as unknown. A hyperlink target holding one is LINK/RELATIVE, which is what pdf-defuse makes of a URI it cannot resolve. A target that loads from outside never counts as a local file. A Word field whose name holds one is FIELD/COMPUTED, and an Excel formula that holds one is DDE/FORMULA, since the unknown text could make it a DDE call. XML 1.0 forbids a reference to an external entity in an attribute value, and xmllint and expat refuse one, so office-defuse refuses it as XML/MALFORMED.
137
+
138
+ The internal subset is read the way XML 1.0 has a processor read it when that processor reads internal parameter entities and no external ones. The first declaration of a name binds. A parameter entity reference that is not read, because the entity is external or undeclared, makes the tokenizer skip the entity and attribute-list declarations after it, unless the XML declaration says `standalone="yes"`. Conditional sections and parameter entity references inside a declaration are refused. libxml2 reads further. It keeps reading declarations after a parameter entity it does not load, and it takes a parameter entity reference inside a declaration that came from another parameter entity's text.
139
+
140
+ An element that leaves out an attribute gets the default its attribute-list declaration gives, namespace declarations included. An attribute declared with a type other than CDATA has its spaces collapsed. expat does both. libxml2 does both for namespace declarations, and applies other defaults only with XML_PARSE_DTDATTR.
141
+
142
+ Three limits bound what a document type can cost: entity references nested 16 deep, 100,000 expansions, and 16 Mi characters of expanded text. The last two count over the whole run, and each attribute default added to an element counts as an expansion. Past one of them the run stops with LIMIT/XML_ENTITY_DEPTH, LIMIT/XML_ENTITY_EXPANSIONS or LIMIT/XML_ENTITY_SIZE. The billion laughs stops at the expansion limit.
143
+
144
+ ### When a run stops
145
+
146
+ A run that stops rejects the file with a null score and the UNKNOWN level, whatever `actionOverrides` say about the finding that stopped it. The containers, a limit, an unrecognized or ambiguous format, an encrypted OpenDocument package and an unexpected error, PACKAGE/UNREADABLE, all stop a run. The findings made before the stop stay in the report.
147
+
148
+ ## Findings
149
+
150
+ Every finding has a `category` and a `detail`, plain strings such as `MACRO` and `VBA_PROJECT`, and a `description` that is safe to show the uploader. A description that quotes a value from the file cuts it to 60 characters and shows control and format characters as `?`. `data` keeps a value from the file up to 200 characters, or 2,000 for a target, and `location` names the part, the relationship or the folder.
151
+
152
+ ```js
153
+ { category: 'EXTERNAL', detail: 'TEMPLATE', description: 'The document loads its template from https://templates.example.net/report.dotm', action: 'strip', location: 'word/_rels/settings.xml.rels, rIdTpl', data: { target: '...', type: '...' } }
154
+ ```
155
+
156
+ Past 200 findings of one kind, one more finding with the location `additional occurrences` counts the rest in `data.count`.
157
+
158
+ `action` is `reject`, `strip` or `info`. Each rule decides its own action, and `info` only reports. The enums are exported as `OfficeCategory` and `OfficeDetail`, and `allFindingSpecs()` lists every pair with its default action, description and weight. `actionOverrides` changes the action by category, or by category and detail:
159
+
160
+ ```js
161
+ await inspectOffice(bytes, { actionOverrides: [{ category: 'MACRO', action: 'reject' }] });
162
+ ```
163
+
164
+ ## Scoring
165
+
166
+ The method is pdf-defuse's. Each kind of finding has a weight: a program run by a click is 80, VBA is 70, a safe link is 0. The highest weight counts in full, every other kind adds a quarter of its weight, and repeats of one kind count once.
167
+
168
+ Three combinations add more. Code here means macros or DDE. Code or a run-program action together with a template, object or include from outside adds 20. Code with an embedded object or ActiveX control adds 15. Code with leading, hidden or trailing archive data adds 10. The total is capped at 100.
169
+
170
+ The bands are NONE at 0, LOW from 1, MEDIUM from 25, HIGH from 50 and CRITICAL from 80. `scoreWeights` and `scoreBands` change them. A finding of a kind office-defuse does not make, such as one from another package, scores at its `weight`, or 0 without one.
171
+
172
+ ## Options
173
+
174
+ | Option | Default | Purpose |
175
+ |---|---|---|
176
+ | `limits.fileSize` | none | Bytes in the upload, checked before any read |
177
+ | `limits.entries` | 10,000 | Entries in the central directory |
178
+ | `limits.decompressedBytes` | 512 MiB | The declared size of any one entry |
179
+ | `limits.totalDecompressedBytes` | 2 GiB | The declared sizes of all entries together |
180
+ | `limits.xmlDepth` | 256 | Element nesting in one XML part |
181
+ | `limits.xmlTokenSize` | 16 Mi | Characters in one tag, comment, processing instruction or CDATA section. A document type declaration counts as one. Text goes through in pieces and has no limit |
182
+ | `limits.xmlEntityDepth` | 16 | Entity references nested inside entity text |
183
+ | `limits.xmlEntityExpansions` | 100,000 | Entity references expanded, and attribute defaults added, in the whole run |
184
+ | `limits.xmlEntitySize` | 16 Mi | Characters of entity text and attribute defaults expanded in the whole run |
185
+ | `limits.timeMs` | none | The whole run |
186
+ | `actionOverrides` | none | Change the action for a category, or for a category and detail |
187
+ | `scoreWeights`, `scoreBands` | built in | Change the scoring |
188
+
189
+ The eight limits with a default bound the memory or work any input can cost, so they apply unless you set them. `Infinity` turns one off. A limit that is reached stops the run with a LIMIT finding.
190
+
191
+ Besides one XML token, the stack of open elements and the declarations of the part's internal subset, office-defuse holds the central directory's table of entries, which parts each part's relationships reach, and, while it reads a relationships part, the Ids that part has given so far, about 100 bytes each, so it can find one given twice. Content types, manifest paths and object references that name no entry of the archive are not kept. Everything else streams.
192
+
193
+ ## The defuse family
194
+
195
+ office-defuse is one of a family of packages that each handle one kind of file, beside [pdf-defuse](https://github.com/patrickdk77/pdf-defuse). It declares the family's shared types with the same shapes as pdf-defuse: `DefuseFinding`, `ByteSource`, `ByteSink`, `PluginContext`, `ContainedFile`, `ContainedFileResult` and `ContainedFilePlugin`. It does not depend on pdf-defuse, and TypeScript matches the types by shape.
196
+
197
+ A finding's `category` and `detail` are plain strings, and `weight`, when set, says how much it adds to a score. The time limit's error carries `code: 'DEFUSE_LIMIT'` and `limit: 'time'`, the code every package of the family passes up the tree of files. `inspectOffice` turns it into LIMIT/TIME.
198
+
199
+ ## What it does not check
200
+
201
+ - The contents of OLE files, such as VBA source, the objects inside `oleObject*.bin`, and ActiveX binaries.
202
+ - Images, fonts and other media in a package, and the inside of an embedded Office package such as a chart's workbook.
203
+ - Formulas in .xlsb sheets, and Excel functions other than DDE that reach outside, such as WEBSERVICE, RTD and IMAGE. Data connections in `connections.xml`.
204
+ - OpenDocument form submit targets, Word's hyperlink base, and whether a link's visible text names a different site than its target.
205
+ - Flat OpenDocument files such as `.fodt`, Flat OPC, and XPS. Inspect rejects them as not a ZIP archive or not a recognized package.
206
+ - Bugs in an application's parsers that ordinary content can trigger.
207
+
208
+ ## Development
209
+
210
+ Development needs Node 22.12 or later. mocha 12 requires it, and the CommonJS test build loads chai 6, an ES module, with `require()`, which Node 22 supports only from 22.12. The pre-commit hook needs Node 22.22.1 or later for lint-staged. The tests also run `python3` and `unzip` to read the ZIP writer's output. The published package has no runtime dependencies and runs on any Node 22.
211
+
212
+ ```sh
213
+ npm install --ignore-scripts
214
+ npm run build
215
+ npm test
216
+ npm run coverage
217
+ npm run lint
218
+ npm run format
219
+ ```
220
+
221
+ `npm run build` compiles `src` into `dist`. `npm test` runs the lint, builds the package and the tests, then runs mocha, so a lint error fails the tests. `npm run coverage` runs the tests under c8 and prints the line and branch coverage of each source file. `npm run lint` runs `biome check` over the whole tree and changes nothing. `npm run format` rewrites files in Biome's format.
222
+
223
+ `npx husky` activates the pre-commit hook. A plain `npm install` does the same through the `prepare` script. The hook runs Biome on the staged files, applying its safe fixes, then runs `npm audit`. The commit stops if either one fails.
224
+
225
+ The fixtures under `test/fixtures` are committed. `python3 -I scripts/make-fixtures.py` rebuilds them, and needs LibreOffice's `soffice`, Python 3 and Info-ZIP's `zip`. It converts flat OpenDocument sources with LibreOffice, adds macros, fields, relationships, objects and controls with Python's `zipfile`, and builds the hostile archives byte by byte.
226
+
227
+ `test/fixtures/cases` holds one small package for each behavior a run over a corpus of real Office files turned up, written from scratch by `python3 -I scripts/make-fixtures.py cases`, which needs only Python. No corpus file, file name or path is in the repository, macro and OLE payloads are inert markers, and every address uses a reserved name. `manifest.json` gives each file's issue, what office-defuse reports for it, and what the build before the fix it documents reported. A test checks every entry, and that the manifest and the folder list the same files.
228
+
229
+ ## License
230
+
231
+ MIT
@@ -0,0 +1,38 @@
1
+ import type { FindingFactory } from './findings';
2
+ import type { Format } from './formats';
3
+ import { OfficeCategory as C, OfficeDetail as D, type DefuseFinding } from './types';
4
+ import { type XmlHandler, type XmlLimits } from './xml';
5
+ import type { ZipArchive, ZipEntry } from './zip';
6
+ /** Cuts text from the file to a length that fits a finding's data. A reference to an external entity shows as `&name;`. */
7
+ export declare const clip: (raw: string, n?: number) => string;
8
+ /** What one inspection holds while the format rules run. */
9
+ export declare class RunContext {
10
+ readonly zip: ZipArchive;
11
+ readonly factory: FindingFactory;
12
+ readonly xmlLimits: XmlLimits;
13
+ readonly findings: DefuseFinding[];
14
+ /** Set as soon as the rules know it, so a run that stops later still names the format. */
15
+ format?: Format;
16
+ private readonly perKind;
17
+ private readonly overflow;
18
+ /** Entity expansion in every part, which the entity limits bound. */
19
+ private readonly budget;
20
+ constructor(zip: ZipArchive, factory: FindingFactory, xmlLimits: XmlLimits);
21
+ /** Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. */
22
+ report(category: C, detail: D, location?: string, data?: Record<string, string | number>): void;
23
+ /** Reports a link, classified by pdf-defuse's checks of its target. */
24
+ link(target: string, location: string, data?: Record<string, string>): void;
25
+ /**
26
+ * Reports a reference that loads from outside the file on its own. It is EXTERNAL/LOCAL_FILE, with `kind` saying
27
+ * what refers, when the target is on this machine, and `detail` otherwise.
28
+ */
29
+ external(detail: D, target: string, location: string, data: Record<string, string>, kind: string): void;
30
+ /**
31
+ * Inflates an entry and tokenizes it as XML, or as Office's VML dialect, handing the events to `handler`. A document
32
+ * type declaration is XML/DOCTYPE without an internal subset and XML/INTERNAL_SUBSET with one. A reference to an
33
+ * external entity in content is XML/EXTERNAL_ENTITY.
34
+ */
35
+ parse(entry: ZipEntry, handler: XmlHandler, vml?: boolean): Promise<void>;
36
+ /** Inflates an entry, which checks it, hands each chunk to `visit`, and tells whether its bytes are an OLE file. */
37
+ drain(entry: ZipEntry, visit?: (chunk: Uint8Array) => void): Promise<boolean>;
38
+ }
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RunContext = exports.clip = void 0;
4
+ const links_1 = require("./links");
5
+ const types_1 = require("./types");
6
+ const xml_1 = require("./xml");
7
+ const OLE_SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
8
+ /** Cuts text from the file to a length that fits a finding's data. A reference to an external entity shows as `&name;`. */
9
+ const clip = (raw, n = 200) => {
10
+ const s = (0, xml_1.showReferences)(raw);
11
+ return s.length > n ? `${s.slice(0, n - 3)}...` : s;
12
+ };
13
+ exports.clip = clip;
14
+ /** What one inspection holds while the format rules run. */
15
+ class RunContext {
16
+ zip;
17
+ factory;
18
+ xmlLimits;
19
+ findings = [];
20
+ /** Set as soon as the rules know it, so a run that stops later still names the format. */
21
+ format;
22
+ perKind = new Map();
23
+ overflow = new Map();
24
+ /** Entity expansion in every part, which the entity limits bound. */
25
+ budget = { expansions: 0, size: 0 };
26
+ constructor(zip, factory, xmlLimits) {
27
+ this.zip = zip;
28
+ this.factory = factory;
29
+ this.xmlLimits = xmlLimits;
30
+ }
31
+ /** Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. */
32
+ report(category, detail, location, data) {
33
+ const k = `${category}/${detail}`;
34
+ const n = (this.perKind.get(k) ?? 0) + 1;
35
+ this.perKind.set(k, n);
36
+ if (n <= 200) {
37
+ this.findings.push(this.factory.make(category, detail, location, data));
38
+ return;
39
+ }
40
+ let counted = this.overflow.get(k);
41
+ if (!counted) {
42
+ // The first overflow fills the description's placeholders.
43
+ counted = { ...data, count: 0 };
44
+ this.overflow.set(k, counted);
45
+ this.findings.push(this.factory.make(category, detail, 'additional occurrences', counted));
46
+ }
47
+ counted.count = Number(counted.count) + 1;
48
+ }
49
+ /** Reports a link, classified by pdf-defuse's checks of its target. */
50
+ link(target, location, data = {}) {
51
+ const v = (0, links_1.checkLink)(target);
52
+ this.report(v.category, v.detail, location, { ...data, target: (0, exports.clip)(target, 2000), ...(v.host ? { host: (0, exports.clip)(v.host) } : {}) });
53
+ }
54
+ /**
55
+ * Reports a reference that loads from outside the file on its own. It is EXTERNAL/LOCAL_FILE, with `kind` saying
56
+ * what refers, when the target is on this machine, and `detail` otherwise.
57
+ */
58
+ external(detail, target, location, data, kind) {
59
+ const d = { ...data, target: (0, exports.clip)(target, 2000) };
60
+ if ((0, links_1.isLocalTarget)(target))
61
+ this.report(types_1.OfficeCategory.External, types_1.OfficeDetail.LocalFile, location, { ...d, kind });
62
+ else
63
+ this.report(types_1.OfficeCategory.External, detail, location, d);
64
+ }
65
+ /**
66
+ * Inflates an entry and tokenizes it as XML, or as Office's VML dialect, handing the events to `handler`. A document
67
+ * type declaration is XML/DOCTYPE without an internal subset and XML/INTERNAL_SUBSET with one. A reference to an
68
+ * external entity in content is XML/EXTERNAL_ENTITY.
69
+ */
70
+ async parse(entry, handler, vml = false) {
71
+ const part = entry.name;
72
+ const notes = {
73
+ doctype: d => {
74
+ const ids = { ...(d.publicId === undefined ? {} : { publicId: (0, exports.clip)(d.publicId) }), ...(d.systemId === undefined ? {} : { systemId: (0, exports.clip)(d.systemId) }) };
75
+ if (!d.internalSubset)
76
+ this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.Doctype, part, { part, name: (0, exports.clip)(d.name), ...ids });
77
+ else {
78
+ const counts = { entities: d.entities, externalEntities: d.externalEntities, attributeDefaults: d.attributeDefaults, skipped: d.skipped };
79
+ this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.InternalSubset, part, { part, name: (0, exports.clip)(d.name), ...ids, ...counts });
80
+ }
81
+ },
82
+ externalEntity: r => this.report(types_1.OfficeCategory.Xml, types_1.OfficeDetail.ExternalEntity, part, { part, entity: (0, exports.clip)(r.name), target: (0, exports.clip)(r.systemId, 2000), ...(r.publicId === undefined ? {} : { publicId: (0, exports.clip)(r.publicId) }) }),
83
+ };
84
+ const t = new xml_1.XmlTokenizer(handler, this.xmlLimits, part, { vml, notes, budget: this.budget });
85
+ for await (const chunk of this.zip.read(entry))
86
+ t.write(chunk);
87
+ t.end();
88
+ }
89
+ /** Inflates an entry, which checks it, hands each chunk to `visit`, and tells whether its bytes are an OLE file. */
90
+ async drain(entry, visit) {
91
+ const head = [];
92
+ for await (const chunk of this.zip.read(entry)) {
93
+ visit?.(chunk);
94
+ for (let i = 0; i < chunk.length && head.length < OLE_SIGNATURE.length; i++)
95
+ head.push(chunk[i]);
96
+ }
97
+ return head.length === OLE_SIGNATURE.length && head.every((b, i) => b === OLE_SIGNATURE[i]);
98
+ }
99
+ }
100
+ exports.RunContext = RunContext;
@@ -0,0 +1,2 @@
1
+ /** Continues a CRC-32, the one ZIP uses, over `bytes`. Start from 0. */
2
+ export declare function crc32(bytes: Uint8Array, crc?: number): number;
package/dist/crc32.js ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.crc32 = crc32;
4
+ // zlib.crc32 arrived in Node 22.2, and the package supports every Node 22.
5
+ const TABLE = new Uint32Array(256);
6
+ for (let n = 0; n < 256; n++) {
7
+ let c = n;
8
+ for (let k = 0; k < 8; k++)
9
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
10
+ TABLE[n] = c >>> 0;
11
+ }
12
+ /** Continues a CRC-32, the one ZIP uses, over `bytes`. Start from 0. */
13
+ function crc32(bytes, crc = 0) {
14
+ let c = ~crc >>> 0;
15
+ for (const b of bytes)
16
+ c = TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
17
+ return ~c >>> 0;
18
+ }
@@ -0,0 +1,5 @@
1
+ import { type ByteSource, type OfficeInspection, type OfficeOptions } from './types';
2
+ /** Inspects an Office or OpenDocument file without changing it. The source is closed when the run ends. */
3
+ export declare function inspectOfficeSource(source: ByteSource, options?: OfficeOptions): Promise<OfficeInspection>;
4
+ /** Inspects Office or OpenDocument bytes. */
5
+ export declare function inspectOffice(bytes: Uint8Array, options?: OfficeOptions): Promise<OfficeInspection>;
package/dist/engine.js ADDED
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inspectOfficeSource = inspectOfficeSource;
4
+ exports.inspectOffice = inspectOffice;
5
+ const context_1 = require("./context");
6
+ const errors_1 = require("./errors");
7
+ const findings_1 = require("./findings");
8
+ const io_1 = require("./io");
9
+ const odf_1 = require("./odf");
10
+ const ooxml_1 = require("./ooxml");
11
+ const score_1 = require("./score");
12
+ const types_1 = require("./types");
13
+ const zip_1 = require("./zip");
14
+ const DEFAULT_LIMITS = {
15
+ entries: 10_000,
16
+ decompressedBytes: 512 * 1024 * 1024,
17
+ totalDecompressedBytes: 2 * 1024 * 1024 * 1024,
18
+ xmlDepth: 256,
19
+ xmlTokenSize: 16 * 1024 * 1024,
20
+ xmlEntityDepth: 16,
21
+ xmlEntityExpansions: 100_000,
22
+ xmlEntitySize: 16 * 1024 * 1024,
23
+ };
24
+ function resolveLimits(given = {}) {
25
+ return {
26
+ entries: given.entries ?? DEFAULT_LIMITS.entries,
27
+ decompressedBytes: given.decompressedBytes ?? DEFAULT_LIMITS.decompressedBytes,
28
+ totalDecompressedBytes: given.totalDecompressedBytes ?? DEFAULT_LIMITS.totalDecompressedBytes,
29
+ xmlDepth: given.xmlDepth ?? DEFAULT_LIMITS.xmlDepth,
30
+ xmlTokenSize: given.xmlTokenSize ?? DEFAULT_LIMITS.xmlTokenSize,
31
+ xmlEntityDepth: given.xmlEntityDepth ?? DEFAULT_LIMITS.xmlEntityDepth,
32
+ xmlEntityExpansions: given.xmlEntityExpansions ?? DEFAULT_LIMITS.xmlEntityExpansions,
33
+ xmlEntitySize: given.xmlEntitySize ?? DEFAULT_LIMITS.xmlEntitySize,
34
+ fileSize: given.fileSize,
35
+ timeMs: given.timeMs,
36
+ };
37
+ }
38
+ /**
39
+ * `incomplete` marks a run that stopped before it read the whole package. It is rejected without a score, whatever
40
+ * the actions say, because an override can make the finding that stopped it look harmless.
41
+ */
42
+ function finish(findings, options, incomplete, format) {
43
+ const named = { format: format?.format ?? '', mimeType: format?.mimeType ?? '' };
44
+ if (incomplete)
45
+ return { status: 'rejected', score: null, risk: types_1.OfficeRisk.Unknown, findings, ...named };
46
+ const rejected = findings.some(f => f.action === 'reject');
47
+ const strippable = findings.some(f => f.action === 'strip');
48
+ const { score, risk } = (0, score_1.scoreFindings)(findings, options);
49
+ return { status: rejected ? 'rejected' : strippable ? 'strippable' : 'clean', score, risk, findings, ...named };
50
+ }
51
+ /**
52
+ * Wraps the source for one run. A read that returns more or fewer bytes than the source's size allows fails with
53
+ * code EIO, since the reader would take the shifted bytes for the file. Any error the source throws is kept, and the
54
+ * engine throws it instead of a verdict on the file.
55
+ */
56
+ function guarded(source) {
57
+ let size = 0;
58
+ const g = {
59
+ size: async () => {
60
+ try {
61
+ size = await source.size();
62
+ return size;
63
+ }
64
+ catch (e) {
65
+ g.failure ??= e;
66
+ throw e;
67
+ }
68
+ },
69
+ read: async (offset, length) => {
70
+ try {
71
+ const b = await source.read(offset, length);
72
+ const want = Math.max(0, Math.min(length, size - offset));
73
+ if (b.length !== want)
74
+ throw Object.assign(new Error(`The source returned ${b.length} bytes at offset ${offset} where ${want} were expected`), { code: 'EIO' });
75
+ return b;
76
+ }
77
+ catch (e) {
78
+ g.failure ??= e;
79
+ throw e;
80
+ }
81
+ },
82
+ };
83
+ return g;
84
+ }
85
+ /** Inspects an Office or OpenDocument file without changing it. The source is closed when the run ends. */
86
+ async function inspectOfficeSource(source, options = {}) {
87
+ const limits = resolveLimits(options.limits);
88
+ const deadline = limits.timeMs !== undefined ? Date.now() + limits.timeMs : undefined;
89
+ const factory = new findings_1.FindingFactory(options.actionOverrides);
90
+ const findings = [];
91
+ const src = guarded(source);
92
+ let ctx;
93
+ try {
94
+ const size = await src.size();
95
+ if (limits.fileSize !== undefined && size > limits.fileSize)
96
+ throw new errors_1.StopError(types_1.OfficeCategory.Limit, types_1.OfficeDetail.FileSize, `${size} bytes`, { size, limit: limits.fileSize });
97
+ (0, errors_1.checkTime)(deadline);
98
+ const zip = await zip_1.ZipArchive.open(src, limits, deadline);
99
+ for (const n of zip.notes)
100
+ findings.push(factory.make(types_1.OfficeCategory.Container, n.detail, undefined, { bytes: n.bytes }));
101
+ ctx = new context_1.RunContext(zip, factory, {
102
+ depth: limits.xmlDepth,
103
+ tokenSize: limits.xmlTokenSize,
104
+ entityDepth: limits.xmlEntityDepth,
105
+ entityExpansions: limits.xmlEntityExpansions,
106
+ entitySize: limits.xmlEntitySize,
107
+ });
108
+ const contentTypes = zip.get('[Content_Types].xml');
109
+ const odf = zip.entries.some(e => e.name === 'mimetype') || zip.get('META-INF/manifest.xml') !== undefined;
110
+ if (contentTypes && odf)
111
+ throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.AmbiguousFormat, 'The file has both [Content_Types].xml and an OpenDocument mimetype or manifest', { reason: 'OOXML and OpenDocument markers' });
112
+ if (contentTypes)
113
+ await (0, ooxml_1.inspectOoxml)(ctx, contentTypes);
114
+ else if (odf)
115
+ await (0, odf_1.inspectOdf)(ctx);
116
+ else
117
+ throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The archive has neither [Content_Types].xml nor an OpenDocument mimetype or manifest', { reason: 'a plain ZIP archive' });
118
+ findings.push(...ctx.findings);
119
+ return finish(findings, options, false, ctx.format);
120
+ }
121
+ catch (e) {
122
+ if (src.failure !== undefined)
123
+ throw src.failure;
124
+ if (ctx)
125
+ findings.push(...ctx.findings);
126
+ if (e instanceof errors_1.StopError) {
127
+ findings.push(factory.make(e.category, e.detail, e.location ?? (typeof e.data?.name === 'string' ? e.data.name : undefined), { ...e.data, reason: e.data?.reason ?? e.message }));
128
+ }
129
+ else if (e?.code === 'DEFUSE_LIMIT') {
130
+ // The family's time limit, from this package or any other.
131
+ findings.push(factory.make(types_1.OfficeCategory.Limit, types_1.OfficeDetail.Time));
132
+ }
133
+ else {
134
+ // Anything unexpected rejects the file rather than escaping as an exception.
135
+ findings.push(factory.make(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unreadable, undefined, { reason: String(e?.message ?? e).slice(0, 200) }));
136
+ }
137
+ return finish(findings, options, true, ctx?.format);
138
+ }
139
+ finally {
140
+ try {
141
+ await source.close?.();
142
+ }
143
+ catch {
144
+ // The verdict stands whether or not the source closes.
145
+ }
146
+ }
147
+ }
148
+ /** Inspects Office or OpenDocument bytes. */
149
+ async function inspectOffice(bytes, options = {}) {
150
+ return inspectOfficeSource((0, io_1.bufferSource)(bytes), options);
151
+ }
@@ -0,0 +1,19 @@
1
+ import type { OfficeCategory, OfficeDetail } from './types';
2
+ /** The run passed its deadline. The code makes every package of the family pass it up instead of treating it as one plugin failing. */
3
+ export declare class TimeLimitError extends Error {
4
+ readonly code = "DEFUSE_LIMIT";
5
+ readonly limit = "time";
6
+ }
7
+ /**
8
+ * A refusal or a limit that stops the run. The engine turns it into the finding it names and rejects the file with
9
+ * no score, whatever the overrides say.
10
+ */
11
+ export declare class StopError extends Error {
12
+ readonly category: OfficeCategory;
13
+ readonly detail: OfficeDetail;
14
+ readonly data?: Record<string, string | number> | undefined;
15
+ readonly location?: string | undefined;
16
+ constructor(category: OfficeCategory, detail: OfficeDetail, message: string, data?: Record<string, string | number> | undefined, location?: string | undefined);
17
+ }
18
+ /** Throws the time limit once `deadline`, a Date.now() value, has passed. */
19
+ export declare function checkTime(deadline: number | undefined): void;
package/dist/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StopError = exports.TimeLimitError = void 0;
4
+ exports.checkTime = checkTime;
5
+ /** The run passed its deadline. The code makes every package of the family pass it up instead of treating it as one plugin failing. */
6
+ class TimeLimitError extends Error {
7
+ code = 'DEFUSE_LIMIT';
8
+ limit = 'time';
9
+ }
10
+ exports.TimeLimitError = TimeLimitError;
11
+ /**
12
+ * A refusal or a limit that stops the run. The engine turns it into the finding it names and rejects the file with
13
+ * no score, whatever the overrides say.
14
+ */
15
+ class StopError extends Error {
16
+ category;
17
+ detail;
18
+ data;
19
+ location;
20
+ constructor(category, detail, message, data, location) {
21
+ super(message);
22
+ this.category = category;
23
+ this.detail = detail;
24
+ this.data = data;
25
+ this.location = location;
26
+ }
27
+ }
28
+ exports.StopError = StopError;
29
+ /** Throws the time limit once `deadline`, a Date.now() value, has passed. */
30
+ function checkTime(deadline) {
31
+ if (deadline !== undefined && Date.now() > deadline)
32
+ throw new TimeLimitError('Time limit exceeded');
33
+ }