@patrickdk77/office-defuse 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # office-defuse
2
2
 
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.
3
+ office-defuse reads Word, Excel, PowerPoint and Visio files, in their ZIP formats and in the compound file formats before them, OpenDocument packages, Outlook messages, Publisher and Project files, and RTF. It reports VBA, WordBasic 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, Equation Editor objects, attachments, ActiveX controls and unsafe links. It scores the risk from 0 to 100.
4
4
 
5
5
  This version inspects and changes nothing.
6
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.
7
+ It has no runtime dependencies and keeps memory bounded. It reads ZIP archives, compound files and XML strictly, and refuses anything two readers could take in different ways. RTF it reads the way Word does, quirks included.
8
8
 
9
9
  ```
10
10
  npm install @patrickdk77/office-defuse
11
11
  ```
12
12
 
13
- Node 22 or later.
13
+ Node 18 or later.
14
14
 
15
15
  ## Quick start
16
16
 
@@ -21,14 +21,14 @@ const report = await inspectOffice(bytes);
21
21
  report.status; // 'clean', 'strippable' or 'rejected'
22
22
  report.risk; // 'NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL' or 'UNKNOWN'
23
23
  report.score; // 0 to 100, null when the file could not be inspected
24
- report.format; // 'docx', 'xlsm', 'odt', ... or '' when not recognized
24
+ report.format; // 'docx', 'xls', 'msg', 'rtf', ... or '' when not recognized
25
25
  report.mimeType; // the MIME type of that format, or ''
26
26
  report.findings; // [{ category, detail, description, action, location, data }]
27
27
  ```
28
28
 
29
29
  ES modules work too: `import { inspectOffice } from '@patrickdk77/office-defuse'`.
30
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.
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 file.
32
32
 
33
33
  ### Files and S3
34
34
 
@@ -52,13 +52,16 @@ await inspectOfficeSource(source);
52
52
 
53
53
  The format comes from the content, never from the file name.
54
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 |
55
+ | Family | ZIP formats | Compound file and RTF formats |
56
+ |---|---|---|
57
+ | Word | .docx .docm .dotx .dotm | .doc .dot from Word 6.0 to 2003, and .rtf |
58
+ | Excel | .xlsx .xlsm .xltx .xltm .xlam .xlsb | .xls .xlt .xla from Excel 5.0 to 2003 |
59
+ | PowerPoint | .pptx .pptm .potx .potm .ppsx .ppsm .ppam .sldx .sldm | .ppt from PowerPoint 97 to 2003 |
60
+ | Visio | .vsdx .vsdm .vstx .vstm .vssx .vssm | .vsd from Visio 2003 to 2010 |
61
+ | OpenDocument | .odt .ott .ods .ots .odp .otp .odg .otg .odf .odm .odb | |
62
+ | Outlook | | .msg .oft |
63
+ | Publisher | | .pub |
64
+ | Project | | .mpp |
62
65
 
63
66
  `[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
67
 
@@ -66,11 +69,22 @@ A ZIP with both markers is rejected as PACKAGE/AMBIGUOUS_FORMAT, since Office co
66
69
 
67
70
  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
71
 
72
+ A file that starts with the compound file signature is a compound file, MS-CFB. The streams and storages at its root name the format: `WordDocument` for Word, `Workbook` or `Book` for Excel, `PowerPoint Document`, `VisioDocument`, `__properties_version1.0` for an Outlook message, a `Quill` storage or Publisher's class id beside a `Contents` stream for Publisher, and a `Props` stream beside a storage named by a number for Project. Word's FIB says whether a document is a template, and Excel's records whether a workbook is a template or an add-in. An Outlook template has a class id of its own. A PowerPoint 97 to 2003 template or show reports its format as .ppt, and a Visio stencil or template as .vsd.
73
+
74
+ A compound file with two of these is PACKAGE/AMBIGUOUS_FORMAT. One with none is PACKAGE/UNRECOGNIZED, and so is a `WordDocument` stream that holds no Word 6.0 to 2003 FIB. A PowerPoint 4.0 file, a bare OLE object and a `vbaProject.bin` on its own all land there.
75
+
76
+ An encrypted .docx, .xlsx or .pptx is a compound file holding `EncryptionInfo` and `EncryptedPackage`. See [Encrypted files](#encrypted-files).
77
+
78
+ A file that starts with `{\rt` is RTF, as Word takes it. A compound file or RTF file in which the ZIP reader also finds an archive is PACKAGE/AMBIGUOUS_FORMAT. A file with the compound file signature whose compound file does not read, but whose ZIP archive does, is that ZIP archive with LEADING_BYTES.
79
+
69
80
  ## What it reports
70
81
 
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`.
82
+ In a ZIP package:
83
+
84
+ - 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 no OLE part of a package, such as `vbaProject.bin`.
72
85
  - 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
86
  - 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.
87
+ - Excel defined names that run a macro sheet: Auto_Open and Auto_Activate as MACRO/EVENT_ON_OPEN, Auto_Close and Auto_Deactivate as MACRO/EVENT. A name counts when it starts with one of these, as `Auto_Open2` does.
74
88
  - PowerPoint and Impress actions that run a program or an object verb.
75
89
  - 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
90
  - Word fields that run a macro, `MACROBUTTON`, and fields whose type another field builds. That is the QUOTE trick that hides `DDEAUTO`.
@@ -82,6 +96,38 @@ A .xlsb gets the package checks. Its sheets are binary records, and the formulas
82
96
 
83
97
  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
98
 
99
+ In a compound file, the first two rules run over every storage of the tree, and the others over the streams of the format:
100
+
101
+ - VBA projects: a `VBA` storage with a `dir` stream, or a `_VBA_PROJECT_CUR` or `Macros` storage with something in it.
102
+ - Embedded objects, by the streams an object's storage holds, under `ObjectPool` or anywhere else: OLE objects with their class id, ProgID and user type, the file a Packager object holds, Equation Editor 3.0 objects by class id or ProgID, ActiveX controls, and linked objects with the target their moniker names. An object's storage is part of the tree, so a macro or an object inside an object is found.
103
+ - Word: WordBasic macros in Word 6.0 and 95 files. The fields of the text, with the rules a .docx gets, and the attached template.
104
+ - Excel: Excel 4.0 macro sheets, by sheet type or by the sheet's own BOF record, and the Auto_ names above, built in or defined. External workbooks, with the paths Excel encodes written out, DDE and OLE links, hyperlinks, shapes and buttons that run a macro, and the `Ctls` stream that holds ActiveX controls.
105
+ - PowerPoint: actions that run a macro, a program or an object verb, hyperlinks, and linked sounds and movies. PowerPoint keeps the storages of objects, controls and the VBA project in its document stream, often compressed. office-defuse inflates them and gives them the rules above.
106
+ - Outlook: each attachment, as EMBEDDED/ATTACHMENT with its name, MIME type and size, and each attachment by reference with its path. An attached message's own attachments are read, 16 messages deep.
107
+ - Visio, Publisher and Project: the VBA projects and objects. Nothing in their own streams is read.
108
+
109
+ In RTF:
110
+
111
+ - Objects in `\objdata`. office-defuse decodes the OLE 1.0 object, the file a Packager object holds, and the storage of an OLE 2 object, which gets the compound file rules. An object linked with `\objlink` or `\objautlink`, or of the class OLE2Link that CVE-2017-0199 uses, is EXTERNAL/OBJECT. `\objocx` marks an ActiveX control. `\objupdate` is ACTION/OBJECT_UPDATE, since it starts the object's program as the document opens.
112
+ - Fields, with the rules a .docx gets, the `\*\template` destination, and shape hyperlinks.
113
+ - Text after the group that closes the document, as TRAILING_BYTES. White space and NULs there are what writers leave, and count for nothing.
114
+
115
+ ### Encrypted files
116
+
117
+ office-defuse decrypts an OOXML package encrypted with Standard or Agile encryption, MS-OFFCRYPTO, and an .xls workbook encrypted with RC4 or RC4 CryptoAPI. It tries the `password` option first, then VelvetSweatshop, the password Excel tries on its own, so such a workbook opens without asking. The decrypted file gets every check of its format. It is read in place. Agile encryption decrypts in 4,096-byte segments and Standard in 16-byte blocks, so only the parts office-defuse reads get decrypted.
118
+
119
+ | Finding | Action | Weight | When |
120
+ |---|---|---|---|
121
+ | ENCRYPTED/DEFAULT_PASSWORD | `strip` | 10 | VelvetSweatshop opened the file. Anyone can open it, so the encryption hides its content from a scanner and from no one else |
122
+ | ENCRYPTED/USER_PASSWORD | `strip` | 0 | The password the caller gave opened the file |
123
+ | ENCRYPTED/RC4_40, RC4_128, RC4_OTHER, AES_128, AES_192, AES_256 | `info` | 0 | The cipher and key size |
124
+ | ENCRYPTED/PASSWORD_REQUIRED | `reject` | 0 | No password at hand opens the file, or an Agile spin count is above 10,000,000. Every encrypted .doc and .ppt, which office-defuse does not decrypt |
125
+ | ENCRYPTED/UNSUPPORTED_ALGORITHM | `reject` | 0 | Excel's XOR obfuscation, or a cipher or hash outside the list below |
126
+
127
+ AES-128, AES-192 and AES-256 in CBC or ECB mode, SHA-1, SHA-256, SHA-384, SHA-512 and MD5 go to Node's crypto module, and no other algorithm name from a file reaches it. RC4 is office-defuse's own code, since OpenSSL 3 keeps RC4 in its legacy provider.
128
+
129
+ Pass the password in code, never on a command line, where other users can read it.
130
+
85
131
  ## The containers
86
132
 
87
133
  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:
@@ -121,6 +167,39 @@ The `Basic/`, `Scripts/`, `Dialogs/` and `Configurations2/` folders of an OpenDo
121
167
 
122
168
  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
169
 
170
+ ### Compound files
171
+
172
+ The compound file reader follows MS-CFB, and where MS-CFB leaves room, it accepts what olefile and LibreOffice accept. It reads the header, the FAT through the DIFAT, the directory, the mini FAT and the mini stream, and records which stream owns each sector, so no sector serves two. It follows a stream's chain only as far as the stream's size needs, as olefile and LibreOffice do, so a chain that runs on past that does no harm. It refuses these, and stops the run:
173
+
174
+ | Detail | Refused |
175
+ |---|---|
176
+ | BAD_HEADER | A file shorter than its header, a version and sector size other than 3 with 512 bytes or 4 with 4,096, mini sectors other than 64 bytes, a mini stream cutoff other than 4,096, a byte order other than little-endian, FAT or DIFAT sector counts that disagree with the sectors listed, a mini FAT too short for the mini stream, or an empty directory |
177
+ | OUT_OF_RANGE | A sector or a stream outside the file, a directory entry that names one past the directory, or a stream size larger than a JavaScript number holds exactly |
178
+ | OVERLAP | A sector or mini sector that two streams take, or a stream and the FAT, DIFAT, directory or mini FAT |
179
+ | CHAIN_LOOP | A FAT, DIFAT or mini FAT chain that comes back to a sector it took already |
180
+ | BROKEN_CHAIN | A chain that runs into a free or reserved sector before its stream ends, or a DIFAT chain that does not end after its last sector |
181
+ | DATA_ERROR | A chain that ends before its stream does |
182
+ | DIRECTORY_LOOP | A directory entry the tree reaches twice |
183
+ | BAD_ENTRY | An entry in the tree of an unknown or unused type, a root entry that is not the first, a name length or terminator that does not fit, a NUL in a name, or a stream with children |
184
+ | UNSAFE_NAME | A name holding `/`, `\`, `:` or `!`, which MS-CFB forbids |
185
+ | DUPLICATE_NAME | Two entries of one storage whose names are equal ignoring case |
186
+
187
+ It reports two more without stopping, each `strip`. HIDDEN_DATA counts the bytes that no stream covers and that are not zero: sectors no chain takes, the rest of each stream's last sector or mini sector, mini sectors no stream takes, directory entries the tree does not reach, and the padding of a version 4 header. Zeros are padding and count for nothing. TRAILING_BYTES is data past the last sector the FAT describes. HIDDEN_DATA is common in real files. LibreOffice, for one, leaves an old copy of a directory sector in a free sector when it saves.
188
+
189
+ `limits.entries` counts directory entries, `limits.decompressedBytes` bounds the size of one stream, and `limits.totalDecompressedBytes` the sizes of all streams together, the way they count ZIP entries. The reader holds the FAT, the mini FAT, the directory and the owner of each sector, and reads streams in ranges as the rules ask for them.
190
+
191
+ ### RTF
192
+
193
+ The RTF reader takes the file in 256 KiB pieces. It holds the destinations open at the point it has reached, the short text it collects, such as a class name or a template path, and the data of each object it decodes. It reads the way Word reads:
194
+
195
+ - A control word's name and its number each end after 250 characters.
196
+ - A destination that `\*` marks and that office-defuse does not know is skipped with its group, and an unknown control word is ignored. Every destination of RTF 1.9.1 is known, so a decoy group such as `{\listtable 4142}` inside an object's data adds no hex digits.
197
+ - In an object's data, anything but hex digits drops out, a digit left unpaired before `\'hh` is dropped, and `\bin` data goes in as its bytes.
198
+ - Inside an object's data, a group that `\*` marks and that opens with a control word that is no destination, such as `{\*\par574 0}`, still holds hex digits for rtfobj, and Word opens files that hide an object's data that way. When the data reads differently with and without those groups, both readings get the object rules, and the findings from the second have `reading` in their data.
199
+ - A file can end inside groups, and the reader closes them. Text after the group that closes the document is still read, as rtfobj reads it.
200
+
201
+ `limits.rtfDepth`, 256 by default, bounds group nesting and the destinations open at once. Past it the run stops with LIMIT/RTF_DEPTH. One object's decoded data counts against `limits.decompressedBytes`, and all of them together against `limits.totalDecompressedBytes`.
202
+
124
203
  ### Document types and entities
125
204
 
126
205
  office-defuse never loads an external DTD and never fetches an external entity. A document type declaration does not stop the run.
@@ -143,11 +222,11 @@ Three limits bound what a document type can cost: entity references nested 16 de
143
222
 
144
223
  ### When a run stops
145
224
 
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.
225
+ 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 container refusals, a limit, an unrecognized or ambiguous format, an encrypted file that no password at hand opens or whose algorithm office-defuse does not decrypt, and an unexpected error, PACKAGE/UNREADABLE, all stop a run. The findings made before the stop stay in the report.
147
226
 
148
227
  ## Findings
149
228
 
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.
229
+ 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, the folder, the stream or storage, or the RTF object's offset.
151
230
 
152
231
  ```js
153
232
  { 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: '...' } }
@@ -165,7 +244,7 @@ await inspectOffice(bytes, { actionOverrides: [{ category: 'MACRO', action: 'rej
165
244
 
166
245
  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
246
 
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.
247
+ 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 container data adds 10. The total is capped at 100.
169
248
 
170
249
  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
250
 
@@ -174,19 +253,21 @@ The bands are NONE at 0, LOW from 1, MEDIUM from 25, HIGH from 50 and CRITICAL f
174
253
  | Option | Default | Purpose |
175
254
  |---|---|---|
176
255
  | `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 |
256
+ | `limits.entries` | 10,000 | Entries in the central directory, or in a compound file's directory |
257
+ | `limits.decompressedBytes` | 512 MiB | The declared size of any one entry or stream, one RTF object's decoded data, or one compressed PowerPoint storage |
258
+ | `limits.totalDecompressedBytes` | 2 GiB | The declared sizes of all entries, or of all streams, together, and all RTF objects' decoded data |
180
259
  | `limits.xmlDepth` | 256 | Element nesting in one XML part |
181
260
  | `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
261
  | `limits.xmlEntityDepth` | 16 | Entity references nested inside entity text |
183
262
  | `limits.xmlEntityExpansions` | 100,000 | Entity references expanded, and attribute defaults added, in the whole run |
184
263
  | `limits.xmlEntitySize` | 16 Mi | Characters of entity text and attribute defaults expanded in the whole run |
264
+ | `limits.rtfDepth` | 256 | Group nesting in an RTF file, and destinations open at once |
185
265
  | `limits.timeMs` | none | The whole run |
266
+ | `password` | none | Tried first on an encrypted OOXML package or .xls workbook, before VelvetSweatshop |
186
267
  | `actionOverrides` | none | Change the action for a category, or for a category and detail |
187
268
  | `scoreWeights`, `scoreBands` | built in | Change the scoring |
188
269
 
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.
270
+ The nine 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
271
 
191
272
  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
273
 
@@ -198,16 +279,22 @@ A finding's `category` and `detail` are plain strings, and `weight`, when set, s
198
279
 
199
280
  ## What it does not check
200
281
 
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`.
282
+ - The OLE parts of a ZIP package, such as `vbaProject.bin`, the objects inside `oleObject*.bin`, and ActiveX binaries.
283
+ - VBA source. A VBA project is reported whole, and its modules go unread.
284
+ - Encrypted .doc and .ppt files, and Excel's XOR obfuscation. They are rejected.
285
+ - Formulas in .xls and .xlsb sheets. A DDE link that a formula uses is found in the workbook's link records.
286
+ - Visio .vsd ShapeSheet cells, and anything in Publisher and Project files but VBA projects and objects.
287
+ - An Outlook message's body, its RTF body included, and what an attached file holds.
288
+ - The piece table of a Word 6.0 or 95 document. Its text is read in the order it is stored between the FIB's bounds, as msodde reads it, so a field that fast save split into pieces stored apart may not read whole.
289
+ - Images, fonts and other media, and the inside of an embedded Office package such as a chart's workbook.
290
+ - Excel functions other than DDE that reach outside, such as WEBSERVICE, RTD and IMAGE. Data connections in `connections.xml`.
204
291
  - OpenDocument form submit targets, Word's hyperlink base, and whether a link's visible text names a different site than its target.
205
292
  - Flat OpenDocument files such as `.fodt`, Flat OPC, and XPS. Inspect rejects them as not a ZIP archive or not a recognized package.
206
293
  - Bugs in an application's parsers that ordinary content can trigger.
207
294
 
208
295
  ## Development
209
296
 
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.
297
+ 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 Node 18 or later.
211
298
 
212
299
  ```sh
213
300
  npm install --ignore-scripts
@@ -222,9 +309,9 @@ npm run format
222
309
 
223
310
  `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
311
 
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.
312
+ 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, which also saves .doc, .xls, .ppt and .rtf versions of some of them. It adds macros, fields, relationships, objects and controls with Python's `zipfile`, and builds the hostile archives byte by byte. Compound files, RTF, Outlook messages and encrypted files come from its own compound file writer, AES and RC4. Two runs write the same bytes: the script sets `SAL_RAND_REPEATABLE` for LibreOffice, and fixes the timestamps and random ids LibreOffice writes.
226
313
 
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.
314
+ `test/fixtures/cases` holds one small file 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
315
 
229
316
  ## License
230
317
 
package/dist/biff.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { CfbEntry, CompoundFile } from './cfb';
2
+ /** Record types this reader acts on (MS-XLS 2.3). */
3
+ export declare const R: {
4
+ bof: number;
5
+ eof: number;
6
+ filePass: number;
7
+ continue: number;
8
+ lbl: number;
9
+ externName: number;
10
+ boundSheet: number;
11
+ template: number;
12
+ addIn: number;
13
+ supBook: number;
14
+ externSheet: number;
15
+ obj: number;
16
+ hlink: number;
17
+ };
18
+ export interface BiffRecord {
19
+ type: number;
20
+ /** Where the record header starts in the stream. */
21
+ offset: number;
22
+ body: Uint8Array;
23
+ }
24
+ /** A sheet as BoundSheet8 (or BIFF5's BOUNDSHEET) lists it. */
25
+ export interface Sheet {
26
+ position: number;
27
+ /** 0 visible, 1 hidden, 2 very hidden. */
28
+ state: number;
29
+ /** 0 worksheet or dialog, 1 macro sheet, 2 chart, 6 VBA module. */
30
+ type: number;
31
+ name: string;
32
+ }
33
+ /**
34
+ * Reads the records of a workbook stream one at a time, from `start` to the end of the stream. A record's body may
35
+ * run past the stream's end only by being cut short, which stops the reading as Excel stops.
36
+ */
37
+ export declare class RecordReader {
38
+ readonly stream: CfbEntry;
39
+ private readonly deadline?;
40
+ private readonly reader;
41
+ private pos;
42
+ constructor(cfb: CompoundFile, stream: CfbEntry, start: number, deadline?: number | undefined);
43
+ next(): Promise<BiffRecord | undefined>;
44
+ }
45
+ /** BIFF8 writes strings with a flag byte: 8-bit characters, or 16-bit ones when its low bit is set. */
46
+ export declare function xlString(b: Uint8Array, at: number, chars: number): {
47
+ text: string;
48
+ next: number;
49
+ };
50
+ /** A BoundSheet record, in BIFF8 or BIFF5. */
51
+ export declare function parseBoundSheet(body: Uint8Array, biff8: boolean): Sheet;
52
+ /** A defined name, in BIFF8 or BIFF5, with a built-in name spelled out. */
53
+ export declare function parseLbl(body: Uint8Array, biff8: boolean): {
54
+ name: string;
55
+ builtin: boolean;
56
+ hidden: boolean;
57
+ };
58
+ /** The event a defined name runs a macro on, when Excel runs it on one: any name that starts with Auto_Open and so on. */
59
+ export declare function autoName(name: string): {
60
+ event: string;
61
+ onOpen: boolean;
62
+ } | undefined;
package/dist/biff.js ADDED
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RecordReader = exports.R = void 0;
4
+ exports.xlString = xlString;
5
+ exports.parseBoundSheet = parseBoundSheet;
6
+ exports.parseLbl = parseLbl;
7
+ exports.autoName = autoName;
8
+ const errors_1 = require("./errors");
9
+ const io_1 = require("./io");
10
+ const oleobj_1 = require("./oleobj");
11
+ /** Record types this reader acts on (MS-XLS 2.3). */
12
+ exports.R = {
13
+ bof: 0x0809,
14
+ eof: 0x000a,
15
+ filePass: 0x002f,
16
+ continue: 0x003c,
17
+ lbl: 0x0018,
18
+ externName: 0x0023,
19
+ boundSheet: 0x0085,
20
+ template: 0x0060,
21
+ addIn: 0x0087,
22
+ supBook: 0x01ae,
23
+ externSheet: 0x0017,
24
+ obj: 0x005d,
25
+ hlink: 0x01b8,
26
+ };
27
+ /**
28
+ * Reads the records of a workbook stream one at a time, from `start` to the end of the stream. A record's body may
29
+ * run past the stream's end only by being cut short, which stops the reading as Excel stops.
30
+ */
31
+ class RecordReader {
32
+ stream;
33
+ deadline;
34
+ reader;
35
+ pos;
36
+ constructor(cfb, stream, start, deadline) {
37
+ this.stream = stream;
38
+ this.deadline = deadline;
39
+ this.reader = new io_1.Reader(cfb.source(stream), stream.size, 16384, 8);
40
+ this.pos = start;
41
+ }
42
+ async next() {
43
+ if (this.pos + 4 > this.stream.size)
44
+ return undefined;
45
+ const h = await this.reader.read(this.pos, 4);
46
+ const type = h[0] | (h[1] << 8);
47
+ const length = h[2] | (h[3] << 8);
48
+ if (this.pos + 4 + length > this.stream.size)
49
+ return undefined;
50
+ const offset = this.pos;
51
+ const body = await this.reader.read(this.pos + 4, length);
52
+ this.pos += 4 + length;
53
+ if ((offset & 0xffff) < 4)
54
+ (0, errors_1.checkTime)(this.deadline);
55
+ return { type, offset, body };
56
+ }
57
+ }
58
+ exports.RecordReader = RecordReader;
59
+ /** BIFF8 writes strings with a flag byte: 8-bit characters, or 16-bit ones when its low bit is set. */
60
+ function xlString(b, at, chars) {
61
+ if (at >= b.length)
62
+ return { text: '', next: at };
63
+ const wide = (b[at] & 1) === 1;
64
+ const start = at + 1;
65
+ const end = Math.min(b.length, start + chars * (wide ? 2 : 1));
66
+ return { text: wide ? (0, oleobj_1.utf16)(b.subarray(start, end)) : (0, oleobj_1.latin1)(b.subarray(start, end)), next: end };
67
+ }
68
+ /** A BoundSheet record, in BIFF8 or BIFF5. */
69
+ function parseBoundSheet(body, biff8) {
70
+ const position = (body[0] | (body[1] << 8) | (body[2] << 16) | (body[3] << 24)) >>> 0;
71
+ const state = body[4] & 3;
72
+ const type = body[5];
73
+ const cch = body[6] ?? 0;
74
+ const name = biff8 ? xlString(body, 7, cch).text : (0, oleobj_1.latin1)(body.subarray(7, 7 + cch));
75
+ return { position, state, type, name };
76
+ }
77
+ /** Built-in names Excel runs as macros (MS-XLS 2.5.112): Auto_Open and Auto_Activate as the workbook opens, Auto_Close and Auto_Deactivate as it closes. */
78
+ const BUILTIN_NAMES = { 1: 'Auto_Open', 2: 'Auto_Close', 10: 'Auto_Activate', 11: 'Auto_Deactivate' };
79
+ /** A defined name, in BIFF8 or BIFF5, with a built-in name spelled out. */
80
+ function parseLbl(body, biff8) {
81
+ const flags = body[0] | (body[1] << 8);
82
+ const cch = body[3] ?? 0;
83
+ const builtin = (flags & 0x20) !== 0;
84
+ const name = biff8 ? xlString(body, 14, cch).text : (0, oleobj_1.latin1)(body.subarray(14, 14 + cch));
85
+ const shown = builtin && name.length > 0 ? (BUILTIN_NAMES[name.charCodeAt(0)] ?? `built-in name ${name.charCodeAt(0)}`) : name;
86
+ return { name: shown, builtin, hidden: (flags & 1) !== 0 };
87
+ }
88
+ /** The event a defined name runs a macro on, when Excel runs it on one: any name that starts with Auto_Open and so on. */
89
+ function autoName(name) {
90
+ const m = /^(?:_xlnm\.)?(auto_open|auto_close|auto_activate|auto_deactivate)/i.exec(name);
91
+ if (!m)
92
+ return undefined;
93
+ const event = m[1].toLowerCase();
94
+ return { event: `${event[0].toUpperCase()}${event.slice(1, 5)}${event[5].toUpperCase()}${event.slice(6)}`, onOpen: event === 'auto_open' || event === 'auto_activate' };
95
+ }
package/dist/cfb.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { type ByteSource, OfficeDetail as D } from './types';
2
+ /** The first eight bytes of every compound file. */
3
+ export declare const CFB_SIGNATURE: Uint8Array<ArrayBuffer>;
4
+ export interface CfbLimits {
5
+ entries: number;
6
+ decompressedBytes: number;
7
+ totalDecompressedBytes: number;
8
+ }
9
+ export interface CfbEntry {
10
+ /** The entry's position in the directory. */
11
+ id: number;
12
+ name: string;
13
+ kind: 'root' | 'storage' | 'stream';
14
+ /** The class id, as Windows writes a GUID, in upper case. */
15
+ clsid: string;
16
+ size: number;
17
+ parent?: CfbEntry;
18
+ children: CfbEntry[];
19
+ }
20
+ /** Something the reader saw that it does not refuse. Each is a strip finding, since a rewrite leaves the bytes out. */
21
+ export interface CfbNote {
22
+ detail: D.HiddenData | D.TrailingBytes;
23
+ bytes: number;
24
+ }
25
+ /** A GUID as Windows shows it: the first three fields little-endian, upper case, without braces. */
26
+ export declare function guid(b: Uint8Array, at?: number): string;
27
+ /** An entry name with control and format characters written as \xNN, so a location can show it. */
28
+ export declare const showName: (name: string) => string;
29
+ /** The entry's path from the root, its names joined by "/", which no name may hold. */
30
+ export declare function entryPath(e: CfbEntry): string;
31
+ /**
32
+ * A compound file (MS-CFB), read from its header, its allocation tables and its directory. Every chain is followed as
33
+ * far as its stream needs and every sector has one owner, so two readers cannot read the file differently. Reading a
34
+ * stream maps its offsets through its chain; nothing is held but the tables.
35
+ */
36
+ export declare class CompoundFile {
37
+ private readonly reader;
38
+ readonly sectorSize: number;
39
+ readonly root: CfbEntry;
40
+ /** Every entry the directory tree reaches, the root first, each storage before its children. */
41
+ readonly entries: CfbEntry[];
42
+ readonly notes: CfbNote[];
43
+ private readonly miniStream;
44
+ private readonly deadline;
45
+ private readonly streamChains;
46
+ private constructor();
47
+ /** The child of a storage with this name, compared ignoring case. */
48
+ child(storage: CfbEntry, name: string): CfbEntry | undefined;
49
+ /** Reads a compound file. The caller has seen its signature. */
50
+ static open(source: ByteSource, limits: CfbLimits, deadline?: number): Promise<CompoundFile>;
51
+ /** Bytes of the mini stream, by their offset in it. */
52
+ private readMini;
53
+ private readChain;
54
+ /** A random-access source over a stream's content. */
55
+ source(e: CfbEntry): ByteSource;
56
+ /** A stream's whole content. Callers read only streams whose size they bounded. */
57
+ bytes(e: CfbEntry): Promise<Uint8Array>;
58
+ }