@patrickdk77/office-defuse 0.2.2 → 0.3.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/README.md CHANGED
@@ -77,7 +77,7 @@ The format comes from the content, never from the file name.
77
77
  |---|---|---|---|
78
78
  | Word | .docx .docm .dotx .dotm | .doc .dot from Word 6.0 to 2003, and .rtf | Flat OPC .xml |
79
79
  | Excel | .xlsx .xlsm .xltx .xltm .xlam .xlsb | .xls .xlt .xla from Excel 5.0 to 2003 | Flat OPC .xml |
80
- | PowerPoint | .pptx .pptm .potx .potm .ppsx .ppsm .ppam .sldx .sldm | .ppt from PowerPoint 97 to 2003 | Flat OPC .xml |
80
+ | PowerPoint | .pptx .pptm .potx .potm .ppsx .ppsm .ppam .sldx .sldm | .ppt from PowerPoint 97 to 2003, and .ppa add-ins | Flat OPC .xml |
81
81
  | Visio | .vsdx .vsdm .vstx .vstm .vssx .vssm | .vsd from Visio 2000 to 2010 | |
82
82
  | XPS | .xps .oxps | | |
83
83
  | OpenDocument | .odt .ott .ods .ots .odp .otp .odg .otg .odf .odm .odb | | .fodt .fods .fodp .fodg |
@@ -91,7 +91,7 @@ A ZIP with both markers is rejected as PACKAGE/AMBIGUOUS_FORMAT, since Office co
91
91
 
92
92
  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.
93
93
 
94
- 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. Neither needs the password of an encrypted file. 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.
94
+ 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. Neither needs the password of an encrypted file. An Outlook template has a class id of its own, and so does a PowerPoint 97 to 2003 add-in, a .ppa, whose root is its VBA project. A PowerPoint 97 to 2003 template or show reports its format as .ppt, and a Visio stencil or template as .vsd.
95
95
 
96
96
  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.
97
97
 
@@ -118,7 +118,7 @@ Detection does not claim a file whose format office-defuse cannot name. That cov
118
118
 
119
119
  In a ZIP package:
120
120
 
121
- - 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`.
121
+ - 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`, unless `scriptPlugins` is set. Then it reads the VBA project, as [Script plugins](#script-plugins) says.
122
122
  - 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.
123
123
  - 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.
124
124
  - 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.
@@ -131,7 +131,7 @@ In a ZIP package:
131
131
  - 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:`.
132
132
  - 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.
133
133
  - A safe link whose label is an address of another site than the one it opens is LINK/TEXT_MISMATCH. A label counts as an address when it is a web URL, a mail address, or a host with or without a path that starts with `www.` or ends in a known top-level domain. Text that only mentions an address names no site, and neither do Excel function names such as `BETA.DIST` and file names such as `moon.htm`, which look like hosts. Sites compare as in pdf-defuse: a subdomain or parent of the label's site matches, and `evil.co.uk` is not under `mybank.co.uk`. The labels are a Word hyperlink's text and tooltip, a HYPERLINK field's result and `\o` tooltip, a DrawingML run's text and a link's tooltip, an Excel hyperlink's display name and tooltip, an OpenDocument link's text and title, and the characters of an XPS Glyphs element. The rewrite removes the link and keeps its text.
134
- - Embedded OLE objects and packages, OLE files nothing declares as an object, Java applets, plug-ins and ActiveX controls.
134
+ - Embedded OLE objects and packages, OLE files nothing declares as an object, Java applets, plug-ins and ActiveX controls. A Forms 2.0 control whose persisted properties read as Office writes them is CONTROL/FORMS_CONTROL, `info`, and stays. See [ActiveX controls](#activex-controls).
135
135
  - 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.
136
136
  - Document type declarations in XML parts, and references to external entities. See [Document types and entities](#document-types-and-entities).
137
137
 
@@ -200,7 +200,7 @@ The check reads the output with the caller's overrides and limits, without `file
200
200
  - A part loses its root element only by going whole. Edits happen in place, and everything the rewrite does not touch keeps its characters, in the part's own encoding, UTF-8 or UTF-16, behind its own byte order mark.
201
201
  - A Word field a strip finding names loses its begin, separator and end marks and its code, nested fields included, and keeps its result. A `w:fldSimple` keeps the runs inside it.
202
202
  - An Excel 4.0 macro sheet becomes an empty worksheet of the same name, with the worksheet content type and relationship type, so the indexes of the sheets after it stay right. A defined name that runs a macro goes. So does a formula that calls DDE, and its cell keeps its cached value.
203
- - A drawing shape loses its `macro` attribute, a ribbon control its callbacks, and a PowerPoint shape an action that runs a program, a macro or an object verb. A Visio cell that calls code goes.
203
+ - A drawing shape loses its `macro` attribute, a ribbon control its callbacks, and a PowerPoint shape an action that runs a program, a macro or an object verb. A Visio cell that calls code goes. When script plugins keep the document's VBA project, a shape, form control, callback, action or MACROBUTTON field that runs one of its macros by name stays.
204
204
  - The main part keeps its content type, so a `.docm` without its VBA project is still a `.docm`.
205
205
  - In an OpenDocument package, `mimetype` comes first, stored, without an extra field, and holds the media type without white space. A package without one gets one, and a package without a manifest gets a manifest listing its entries. `Basic/`, `Scripts/`, `Dialogs/` and `Configurations2/` go whole. An entry goes with its manifest entry and with every element whose `xlink:href` names it. A link `text:a` or `draw:a` keeps what it holds. A form control loses its link, and a cell loses a formula that calls DDE. An embedded OLE object goes and the preview image beside it stays.
206
206
  - A document type declaration with an internal subset goes when XML/INTERNAL_SUBSET strips it. Each reference to an internal entity is written out as its text, markup included, and each start tag whose attributes the declarations changed or added to is written out whole, namespace declarations included. The part then reads as the inspection read it. References to external entities go when XML/EXTERNAL_ENTITY strips them. If that finding is overridden to `info` while the subset would go, the references still need their declarations, so the declaration stays and the check of the output reports it.
@@ -216,13 +216,50 @@ A Flat OPC package is rewritten as the package of its parts is, then written bac
216
216
 
217
217
  ### Compound files
218
218
 
219
- The rewrite writes a new compound file, version 3 with 512-byte sectors, without the streams and storages a strip finding names: VBA project storages, the storages of embedded objects, linked objects and ActiveX controls, Excel's `Ctls` stream, and Outlook attachments. A message's property stream counts its attachments, and the count drops by the ones that went.
219
+ The rewrite writes a new compound file, version 3 with 512-byte sectors, without the streams and storages a strip finding names: VBA project storages, the storages of embedded objects, linked objects and ActiveX controls, Excel's `Ctls` stream, Word's picture for printing a control, and Outlook attachments. A VBA project that script plugins keep is written without its compiled code. A message's property stream counts its attachments, and the count drops by the ones that went.
220
220
 
221
221
  The FAT comes right after the header, so no stream lands in the first 1024 bytes, where PDF readers look for a header. When a stream ends with a ZIP end record, as an embedded package does, free sectors of zeros follow the last stream until the record is more than 65,557 bytes from the end of the file, past where ZIP readers look.
222
222
 
223
223
  An Outlook message's RTF body with a strip finding is rewritten the way an RTF file is, and stored back uncompressed, which MS-OXRTFCP allows. A body that does not decompress, or one with a `reject` finding inside, goes, and the message keeps its plain text body. The property stream entry of a body that went goes too, and that of a rewritten body takes its new size. Every other stream is copied unchanged. Sectors no stream holds, the rest of each stream's last sector and data past the last sector are not written.
224
224
 
225
- Content inside a stream stays as it was. That covers Word's fields, template and WordBasic macros, Excel's records for macro sheets, defined names, links, hyperlinks and buttons, every record of a PowerPoint presentation along with the objects stored in its records, the ShapeSheet cells of a Visio drawing, and the RC4 encryption of a workbook. A finding there is still in the output, so the output fails its check and the upload is rejected.
225
+ Excel's and PowerPoint's records are written again, as the next two sections say. Content inside any other stream stays as it was: Word's fields, template and WordBasic macros, Excel's cell formulas, and the ShapeSheet cells of a Visio drawing. A finding there is still in the output, so the output fails its check and the upload is rejected.
226
+
227
+ ### Excel workbooks
228
+
229
+ A workbook stream, Excel 97 to 2003 or Excel 5.0 and 95, is written again record by record, in the order the stream holds them:
230
+
231
+ - An Excel 4.0 macro sheet becomes an empty worksheet of the same name and visibility, so formulas and names keep their sheet numbers.
232
+ - A name that runs a macro, such as Auto_Open, is renamed `Removed_Auto_Open_1` and so on, and is no longer a built-in name. Formulas refer to names by number, so removing it would point them at the wrong name.
233
+ - A button or shape loses the macro it runs, unless the workbook's VBA project stays and the macro is one of its own: one name of the SupBook that is the workbook itself, as Excel names a VBA macro. A defined name can stand for anything, so a button that runs one loses it.
234
+ - A hyperlink goes with its tooltip.
235
+ - An external workbook, DDE or OLE link keeps its SupBook record, which formulas count to by number, with a path that is the workbook itself, U+0002.
236
+ - A workbook encrypted with VelvetSweatshop is written decrypted, without its FILEPASS record. So is any encrypted workbook that is written again, since office-defuse does not encrypt.
237
+
238
+ Records change size, so the stream positions other records hold are written anew: each sheet's in BoundSheet8, the DefColWidth and DBCell positions in Index, a DBCell's offset back to its first row, and ExtSST's string positions. A position that lands where no record starts, or inside a record that changed, leaves the stream as it was, and the output check rejects the upload. Excel 5.0 and 95 workbooks get the sheet and name edits only. A cell formula that calls EXEC, CALL or WEBSERVICE is not written again.
239
+
240
+ ### PowerPoint presentations
241
+
242
+ A presentation is written as a full save: each persist object the current edit reaches, in persist id order, then one persist directory and one UserEditAtom with no edit before it, which the Current User stream points at. A container's length follows the records left in it.
243
+
244
+ - An embedded object, a linked object or an ActiveX control that goes takes its ExOleObjStg and its container in ExObjList with it. A Forms 2.0 control stays. An object a file plugin rewrote takes the plugin's bytes, compressed again when PowerPoint stored it compressed.
245
+ - A VBA project that goes takes its VBAInfoContainer with it. One that script plugins keep is written without its compiled code inside its compressed storage.
246
+ - An action that runs a program, a macro or an OLE verb becomes no action. A macro of a kept VBA project keeps its action.
247
+ - A hyperlink that goes takes its ExHyperlinkContainer, and the actions that open it become no action.
248
+ - A linked movie or sound loses its path.
249
+ - An encrypted presentation that is written again is written decrypted, without its encryption session. Its Pictures and EncryptedSummary streams are encrypted too, and office-defuse does not decrypt them, so a presentation that has either stays as it was and the output check rejects it.
250
+
251
+ ## ActiveX controls
252
+
253
+ An ActiveX control runs whatever code its class names, so a control goes unless office-defuse can tell it is one of the Forms 2.0 controls Office ships, as Office persisted it: CommandButton, Label, TextBox, ListBox, ComboBox, CheckBox, OptionButton, ToggleButton, SpinButton or ScrollBar. Such a control is CONTROL/FORMS_CONTROL, `info`, and stays, with or without a VBA project. An override to `strip` removes it like any other control.
254
+
255
+ office-defuse reads the control's properties as MS-OFORMS lays them out and as LibreOffice reads them, and keeps the control when:
256
+
257
+ - its class id, or the ProgID its CompObj, ax:ocx element or Obj record names, is one of those controls;
258
+ - every bit of its property mask names a property MS-OFORMS defines;
259
+ - it holds no picture or mouse icon, whose bytes are a picture file Office decodes;
260
+ - its properties, its font and, for a list, its column infos fill its data to the last byte.
261
+
262
+ The control can sit in an OOXML activeX part, persisted as a stream that starts with the class id, as a storage with a contents stream, or as a property bag with plain values. In a Word document it is a storage under ObjectPool, and its `\x03PRINT` stream, a Windows metafile Word prints in its place, goes as CONTROL/CONTROL_PICTURE. In an Excel workbook every control is in one `Ctls` stream, which stays only when the Obj records place Forms 2.0 controls over all of it. Any other control, a Forms 2.0 control with a picture, and a frame, multipage or tab strip are CONTROL/ACTIVEX with `data.reason`.
226
263
 
227
264
  ### RTF
228
265
 
@@ -248,9 +285,11 @@ A file comes as a `ContainedFile`. `name` is the part or entry name, a Packager
248
285
  | `removed` | goes, with every reference to it | EMBEDDED/PLUGIN_REMOVED, `strip` |
249
286
  | `scrubbed` with nothing written, or an error | goes, with every reference to it | EMBEDDED/PLUGIN_FAILED, `strip` |
250
287
 
251
- A file goes whatever the overrides say about these findings. A file no plugin accepts gets the rule it gets without plugins, such as EMBEDDED/OLE_OBJECT, and a picture, which no rule names, stays. When a plugin keeps a file, the findings that name the file for being there, such as EMBEDDED/PACKAGE or MACRO/VBA_PROJECT for `vbaProject.bin`, turn to `info` and leave the score, as pdf-defuse leaves out an attached file a plugin kept. Other rules still decide for themselves, so an Equation Editor object a plugin passes goes anyway, since EMBEDDED/EQUATION strips it. A plugin's output for an object's storage must be a compound file, whose storages and streams then take the storage's place. Where the rewrite cannot change the container, as for an object stored in a PowerPoint record, the output keeps the file as it arrived, so a plugin's `scrubbed` or `removed` there fails the output check.
288
+ A file goes whatever the overrides say about these findings. A file no plugin accepts gets the rule it gets without plugins, such as EMBEDDED/OLE_OBJECT, and a picture, which no rule names, stays. A file whose plugin failed gets that rule too, and goes.
252
289
 
253
- The findings a plugin returns go into the report with their `location` after the file's own and with `attachment` naming the file. A `reject` inside the file becomes `strip`, since the file goes and the file holding it stays. Each scores at its `weight`. The upload's score leaves out PLUGIN_PASSED and PLUGIN_SCRUBBED, and the output's counts them. The check of the output does not ask the plugins again. It matches each file by the MD5 of its bytes against what the plugins kept, and reports the `outputFindings` the plugin gave for it.
290
+ A plugin's answer is final. A file a plugin passed, scrubbed or removed gets none of office-defuse's own findings: no EMBEDDED/PACKAGE, EMBEDDED/OLE_OBJECT or EMBEDDED/EQUATION, and no MACRO/VBA_PROJECT for `vbaProject.bin`. office-defuse does not look inside it either, so an object's storage is not searched for VBA projects or other objects. An Equation Editor object a plugin passes stays. Through file-defuse no plugin takes a bare object storage, so EMBEDDED/EQUATION still removes one there. A plugin's output for an object's storage must be a compound file, whose storages and streams then take the storage's place. Where the rewrite cannot change the container, as for an object stored in a PowerPoint record, the output keeps the file as it arrived, so a plugin's `scrubbed` or `removed` there fails the output check.
291
+
292
+ The findings a plugin returns go into the report with their `location` after the file's own and with `attachment` naming the file. A `reject` inside the file becomes `strip`, since the file goes and the file holding it stays. Each scores at its `weight`, and they are all the file adds to the score. PLUGIN_PASSED and PLUGIN_SCRUBBED weigh 0, in the upload's score and the output's. The check of the output does not ask the plugins again. It matches each file by the MD5 of its bytes against what the plugins kept, and reports the `outputFindings` the plugin gave for it.
254
293
 
255
294
  The plugin context has `depth`, the file's depth in the upload, and `deadline`, which every file of the upload shares. An error with `code: 'DEFUSE_LIMIT'` or `code: 'DEFUSE_IO'` from a plugin stops the whole upload. At depth 0, a limit becomes LIMIT/TIME or LIMIT/NESTING_DEPTH, and an I/O error is thrown as its cause.
256
295
 
@@ -262,6 +301,42 @@ const { disarmOffice, officePlugin } = require('@patrickdk77/office-defuse');
262
301
  await disarmOffice(bytes, { filePlugins: [officePlugin()] });
263
302
  ```
264
303
 
304
+ ## Script plugins
305
+
306
+ `scriptPlugins` takes script plugins that decide about VBA. office-defuse reads a VBA project's dir stream and modules as MS-OVBA describes them, and shows each module to the plugins as a `ContainedScript`:
307
+
308
+ | Field | What it holds |
309
+ |---|---|
310
+ | `text` | The module's source, decoded with the project's code page |
311
+ | `trigger` | `vba-document`, `vba-standard`, `vba-class` or `vba-form`, as the PROJECT stream lists the module |
312
+ | `location` | Where the module sits, such as `xl/vbaProject.bin > VBA/Module1` |
313
+ | `fieldName` | The module's name |
314
+ | `language`, `form` | `vba` and `office` |
315
+ | `references` | On the first module only, the project's references, each `{ name, kind, libid }` |
316
+
317
+ The first plugin whose `accepts` returns true decides a module. A module it passes gets MACRO/PLUGIN_PASSED, `info`. Any other answer, `scrubbed` included, is MACRO/PLUGIN_REMOVED, and an error is MACRO/PLUGIN_FAILED, both `strip`, since office-defuse writes no module a plugin rewrote. The plugin's findings go into the report as they are.
318
+
319
+ The project stays when every module passed. Otherwise it goes, as it does without plugins, and so does a project with a module no plugin took. A project that stays is written without its compiled code, the way Mandiant's OfficePurge writes one. Each module stream holds only its source, compressed again, the dir stream says each source starts at 0, `_VBA_PROJECT` names no VBA version, and the `__SRP_` caches go. Office then compiles the source the plugins read, so p-code that does something other than its source, which VBA stomping relies on, never runs. MACRO/COMPILED_CODE, `strip`, reports the purge.
320
+
321
+ When a plugin passed or removed every module, the answers are final. The report holds MACRO/PLUGIN_PASSED and PLUGIN_REMOVED with the plugins' findings, and no MACRO/VBA_PROJECT, and the project scores what the plugins found in it. MACRO/VBA_PROJECT, at office-defuse's weight, reports a project with a module no plugin took or a plugin failed on, or one office-defuse removes for a reason of its own, such as a user form that holds an ActiveX control.
322
+
323
+ A user form stays with its project when its designer storage holds Forms 2.0 controls only. The designer must be the UserForm designer, the storages under it must be Forms 2.0 forms, frames and multipages, and every form's class table must be empty, since a class table can name any ActiveX control. A form that fails gets CONTROL/ACTIVEX at its designer, and while that finding strips, the project goes.
324
+
325
+ When the document's own project stays, so does what runs its macros by name: an Excel shape's `macro`, a form control's `x:FmlaMacro`, a ribbon callback, PowerPoint's `ppaction://macro` and Word's MACROBUTTON field. Their findings turn to `info`. A name that points into another file, as `'other.xlsm'!Macro`, still goes. Once the plugins answered for the project, kept or removed, these findings weigh 0, since the plugins scored the macros they run.
326
+
327
+ office-defuse decides the `vbaProject.bin` part of an OOXML package, which with `scriptPlugins` set, even to an empty list, goes to the script plugins and not to the file plugins. It decides the project storages of a Word document, a workbook, a PowerPoint 97 presentation, a .ppa add-in and the other compound files. A project inside an embedded object is for the file plugins that take the object.
328
+
329
+ A source that does not decompress, or a dir stream that names a module stream that is not there, leaves nothing to check. The project goes, and MACRO/VBA_PROJECT's `data.reason` says why. The check of the output does not ask the plugins again. It matches the project by the MD5 of its references and module sources, reports the plugins' findings again, and reports MACRO/COMPILED_CODE if the output still holds p-code, which fails the check.
330
+
331
+ [script-defuse](https://github.com/patrickdk77/script-defuse)'s `formScriptPlugin` is a script plugin for VBA:
332
+
333
+ ```js
334
+ const { disarmOffice } = require('@patrickdk77/office-defuse');
335
+ const { formScriptPlugin } = require('@patrickdk77/script-defuse');
336
+
337
+ await disarmOffice(bytes, { scriptPlugins: [formScriptPlugin()] });
338
+ ```
339
+
265
340
  ## The containers
266
341
 
267
342
  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:
@@ -382,6 +457,8 @@ Three combinations add more. Code here means macros or DDE. Code or a run-progra
382
457
 
383
458
  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.
384
459
 
460
+ Content a plugin answered for scores at the weights of the plugin's findings alone, as [File plugins](#file-plugins) and [Script plugins](#script-plugins) describe. A workbook whose VBA project script-defuse keeps scores only what is left, such as the 10 of MACRO/COMPILED_CODE.
461
+
385
462
  ## Options
386
463
 
387
464
  | Option | Default | Purpose |
@@ -402,6 +479,7 @@ The bands are NONE at 0, LOW from 1, MEDIUM from 25, HIGH from 50 and CRITICAL f
402
479
  | `actionOverrides` | none | Change the action for a category, or for a category and detail |
403
480
  | `scoreWeights`, `scoreBands` | built in | Change the scoring |
404
481
  | `filePlugins` | none | Plugins that decide about the files inside the file. See [File plugins](#file-plugins) |
482
+ | `scriptPlugins` | none | Plugins that decide about the modules of a VBA project. See [Script plugins](#script-plugins) |
405
483
  | `tempDir` | `os.tmpdir()` | Where temporary files go |
406
484
  | `memoryThreshold` | 8 MiB | Disarm keeps its copy of the upload, its output and each file it shows a plugin in memory up to this size, and in a temporary file past it. The copies plugins return share this much memory while they wait for the rewrite |
407
485
 
@@ -447,8 +525,8 @@ Exit codes are 0 for clean, 1 for defused or strippable, 2 for rejected and 3 fo
447
525
 
448
526
  ## What it does not check
449
527
 
450
- - The OLE parts of a ZIP package, such as `vbaProject.bin`, the objects inside `oleObject*.bin`, and ActiveX binaries. office-defuse hands them to the file plugins and reads none of them itself.
451
- - VBA source. A VBA project is reported whole, and its modules go unread.
528
+ - The OLE parts of a ZIP package, such as the objects inside `oleObject*.bin`, the binaries of ActiveX controls other than Forms 2.0 ones, and `vbaProject.bin` without script plugins. office-defuse hands them to the file plugins and reads none of them itself.
529
+ - VBA source. office-defuse reads the modules only to show them to script plugins, and without plugins reports a project whole. In a user form that stays, the controls' data and pictures in its `o` stream go unread.
452
530
  - Encrypted Word 6.0 and 95 documents. They are rejected. In a Word document encrypted with RC4 CryptoAPI, the OLE objects that Word keeps in the Data stream rather than in ObjectPool. The pictures of an encrypted presentation.
453
531
  - Formulas in .xlsb sheets, and in an Excel 5.0 or 95 workbook. A DDE link that a formula uses is found in the workbook's link records.
454
532
  - Hyperlinks in a Visio .vsd, ShapeSheet cells in a drawing from Visio 5.0 or earlier, and anything in Publisher and Project files but VBA projects and objects.
package/dist/biff.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface BiffRecord {
24
24
  /** A sheet as BoundSheet8 (or BIFF5's BOUNDSHEET) lists it. */
25
25
  export interface Sheet {
26
26
  position: number;
27
+ /** Where its BoundSheet record starts in the stream. */
28
+ record?: number;
27
29
  /** 0 visible, 1 hidden, 2 very hidden. */
28
30
  state: number;
29
31
  /** 0 worksheet or dialog, 1 macro sheet, 2 chart, 6 VBA module. */
@@ -42,6 +44,15 @@ export declare class RecordReader {
42
44
  constructor(cfb: CompoundFile, stream: CfbEntry, start: number, deadline?: number | undefined);
43
45
  next(): Promise<BiffRecord | undefined>;
44
46
  }
47
+ /**
48
+ * The subrecords of a BIFF8 Obj record (MS-XLS 2.4.181), each with its type, where its header starts and its data. The
49
+ * walk stops at FtEnd, and at FtLbsData, whose length does not count all its data.
50
+ */
51
+ export declare function objSubrecords(b: Uint8Array): Generator<{
52
+ ft: number;
53
+ at: number;
54
+ data: Uint8Array;
55
+ }>;
45
56
  /** BIFF8 writes strings with a flag byte: 8-bit characters, or 16-bit ones when its low bit is set. */
46
57
  export declare function xlString(b: Uint8Array, at: number, chars: number): {
47
58
  text: string;
package/dist/biff.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RecordReader = exports.R = void 0;
4
+ exports.objSubrecords = objSubrecords;
4
5
  exports.xlString = xlString;
5
6
  exports.parseBoundSheet = parseBoundSheet;
6
7
  exports.parseLbl = parseLbl;
@@ -56,6 +57,20 @@ class RecordReader {
56
57
  }
57
58
  }
58
59
  exports.RecordReader = RecordReader;
60
+ /**
61
+ * The subrecords of a BIFF8 Obj record (MS-XLS 2.4.181), each with its type, where its header starts and its data. The
62
+ * walk stops at FtEnd, and at FtLbsData, whose length does not count all its data.
63
+ */
64
+ function* objSubrecords(b) {
65
+ for (let at = 0; at + 4 <= b.length;) {
66
+ const ft = b[at] | (b[at + 1] << 8);
67
+ const cb = b[at + 2] | (b[at + 3] << 8);
68
+ if (ft === 0x00 || ft === 0x13)
69
+ return;
70
+ yield { ft, at, data: b.subarray(at + 4, at + 4 + cb) };
71
+ at += 4 + cb;
72
+ }
73
+ }
59
74
  /** BIFF8 writes strings with a flag byte: 8-bit characters, or 16-bit ones when its low bit is set. */
60
75
  function xlString(b, at, chars) {
61
76
  if (at >= b.length)
@@ -1,6 +1,6 @@
1
- import type { RunContext, RunState } from './context';
1
+ import type { Reported, RunContext, RunState } from './context';
2
2
  import { SpillSink } from './io';
3
- import { type DefuseFinding, type OfficeOptions, type PluginContext } from './types';
3
+ import { OfficeCategory as C, OfficeDetail as D, type DefuseFinding, type OfficeOptions, type PluginContext } from './types';
4
4
  /** The key under which office-defuse hands its run's options to a plugin. No other package can name it. */
5
5
  export declare const RUN_OPTIONS: unique symbol;
6
6
  /** A plugin context as office-defuse makes it. */
@@ -10,6 +10,11 @@ export type RunPluginContext = PluginContext & {
10
10
  /** What the plugins decided about a file inside the file. Undefined from `decideContained` means no plugin took it. */
11
11
  export interface ContainedDecision {
12
12
  keep: boolean;
13
+ /**
14
+ * A plugin passed, cleaned or removed the file. Its answer is final: the report holds it and what the plugin found,
15
+ * and office-defuse reports nothing of its own about the file.
16
+ */
17
+ answered?: boolean;
13
18
  /** The bytes a plugin kept in place of the file's own. */
14
19
  replacement?: SpillSink;
15
20
  }
@@ -22,6 +27,11 @@ export interface InnerFile {
22
27
  /** The file's bytes, in order. */
23
28
  chunks(): AsyncIterable<Uint8Array>;
24
29
  }
30
+ /**
31
+ * Throws on an error a plugin threw that stops the whole tree. An I/O error fails this run as one of its own would,
32
+ * so its cause goes up.
33
+ */
34
+ export declare function passOn(run: RunState, e: unknown): void;
25
35
  /**
26
36
  * Adds the findings a plugin reported from inside a file, labeled with the file's name and place. A reject inside the
27
37
  * file means the file goes, not the one that holds it, so it is recorded here as strip.
@@ -38,9 +48,9 @@ export declare function pushContained(ctx: RunContext, found: DefuseFinding[] |
38
48
  */
39
49
  export declare function decideContained(ctx: RunContext, inner: InnerFile): Promise<ContainedDecision | undefined>;
40
50
  /**
41
- * The rule a file gets for being there, such as EMBEDDED/OLE_OBJECT, applies when no plugin took the file. A plugin
42
- * that kept the file turns those findings to info, since the content stays.
51
+ * Reports the rule a file gets for being there, such as EMBEDDED/OLE_OBJECT, when no plugin answered for the file. A
52
+ * plugin's answer is final: when one answered, nothing is reported, and the result only says whether the file stays.
43
53
  */
44
- export declare function presence(decision: ContainedDecision | undefined): 'info' | undefined;
54
+ export declare function ownFinding(ctx: RunContext, decision: ContainedDecision | undefined, category: C, detail: D, location: string, data?: Record<string, string | number>): Reported;
45
55
  /** Bytes in a spill sink the run holds until it ends, as the rewrite takes a plugin's output. */
46
56
  export declare function spillOf(run: RunState, bytes: Uint8Array): Promise<SpillSink>;
package/dist/contained.js CHANGED
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RUN_OPTIONS = void 0;
4
+ exports.passOn = passOn;
4
5
  exports.pushContained = pushContained;
5
6
  exports.decideContained = decideContained;
6
- exports.presence = presence;
7
+ exports.ownFinding = ownFinding;
7
8
  exports.spillOf = spillOf;
8
9
  const errors_1 = require("./errors");
9
10
  const io_1 = require("./io");
@@ -64,7 +65,7 @@ async function decideContained(ctx, inner) {
64
65
  if (expected) {
65
66
  ctx.report(types_1.OfficeCategory.Embedded, expected.result === 'passed' ? types_1.OfficeDetail.PluginPassed : types_1.OfficeDetail.PluginScrubbed, inner.location, { plugin: expected.plugin, name: name ?? '' });
66
67
  pushContained(ctx, expected.findings, inner.location, name);
67
- return { keep: true };
68
+ return { keep: true, answered: true };
68
69
  }
69
70
  if (!plugins.length)
70
71
  return undefined;
@@ -107,6 +108,7 @@ async function decideContained(ctx, inner) {
107
108
  }
108
109
  else
109
110
  ctx.removal(types_1.OfficeCategory.Embedded, r.result === 'removed' ? types_1.OfficeDetail.PluginRemoved : types_1.OfficeDetail.PluginFailed, inner.location, data);
111
+ const answered = keep || r.result === 'removed';
110
112
  pushContained(ctx, r.findings, inner.location, name);
111
113
  const replacement = keep && out.length > 0 ? out : undefined;
112
114
  if (replacement) {
@@ -116,7 +118,7 @@ async function decideContained(ctx, inner) {
116
118
  }
117
119
  else
118
120
  await out.dispose();
119
- return { keep, replacement };
121
+ return { keep, replacement, answered };
120
122
  }
121
123
  catch (e) {
122
124
  await out.dispose();
@@ -136,11 +138,13 @@ async function decideContained(ctx, inner) {
136
138
  }
137
139
  }
138
140
  /**
139
- * The rule a file gets for being there, such as EMBEDDED/OLE_OBJECT, applies when no plugin took the file. A plugin
140
- * that kept the file turns those findings to info, since the content stays.
141
+ * Reports the rule a file gets for being there, such as EMBEDDED/OLE_OBJECT, when no plugin answered for the file. A
142
+ * plugin's answer is final: when one answered, nothing is reported, and the result only says whether the file stays.
141
143
  */
142
- function presence(decision) {
143
- return decision?.keep ? 'info' : undefined;
144
+ function ownFinding(ctx, decision, category, detail, location, data) {
145
+ if (decision?.answered)
146
+ return { action: decision.keep ? 'info' : 'strip' };
147
+ return ctx.report(category, detail, location, data);
144
148
  }
145
149
  /** Bytes in a spill sink the run holds until it ends, as the rewrite takes a plugin's output. */
146
150
  async function spillOf(run, bytes) {
package/dist/context.d.ts CHANGED
@@ -68,15 +68,17 @@ export interface RunState {
68
68
  kept: Map<string, Expected>;
69
69
  /** Plugin output that waits for the rewrite. The run disposes of it when it ends. */
70
70
  held: SpillSink[];
71
- /**
72
- * Findings whose content a plugin kept, such as EMBEDDED/PACKAGE for a package a plugin passed. They stay in the
73
- * report as info and leave the score to what the plugin found, as pdf-defuse leaves out a kept attached file.
74
- */
75
- superseded: WeakSet<DefuseFinding>;
76
71
  /** Sources the rewrite reads from, such as a compound file a plugin returned, which the run closes when it ends. */
77
72
  sources: ByteSource[];
78
73
  /** The container, once the format rules have read it. */
79
74
  output?: Output;
75
+ /**
76
+ * The document's own VBA project stays, as the script plugins passed it. The buttons, shapes, callbacks and fields
77
+ * that run one of its macros by name then stay too.
78
+ */
79
+ macrosKept?: boolean;
80
+ /** The script plugins answered for the document's own VBA project, whether they kept it or removed it. */
81
+ macrosAnswered?: boolean;
80
82
  }
81
83
  /** A finding the rules made, with its action, which the rewrite reads. Past 200 of one kind, `finding` is absent. */
82
84
  export interface Reported {
@@ -115,11 +117,23 @@ export declare class RunContext {
115
117
  withPlan(): PlanContext;
116
118
  /**
117
119
  * Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. `action` replaces the
118
- * action the overrides give, for content whose fate a plugin decided.
120
+ * action the overrides give, for content whose fate a plugin decided. Such a finding as info weighs nothing.
119
121
  */
120
122
  report(category: C, detail: D, location?: string, data?: Record<string, string | number>, action?: OfficeFindingAction): Reported;
123
+ /**
124
+ * Reports a link, callback or field that runs the macro `name`. When the script plugins answered for the document's
125
+ * own VBA project, a trigger of one of its macros follows their answer: it stays, as info, when they kept the
126
+ * project, and the overrides decide it when they removed the project. Either way it weighs nothing, since the
127
+ * plugins scored the macro it runs.
128
+ */
129
+ macroTrigger(category: C, detail: D, location: string, data: Record<string, string | number>, name: string | undefined): Reported;
121
130
  /** Reports content that goes whatever the overrides say: its action is strip, or reject when the caller asked for that. */
122
131
  removal(category: C, detail: D, location?: string, data?: Record<string, string | number>): Reported;
132
+ /**
133
+ * Takes back a finding reported before a plugin answered for its content, whose answer is final. A finding past the
134
+ * first 200 of its kind was only counted, so there is none to take back.
135
+ */
136
+ retract(finding: DefuseFinding | undefined): void;
123
137
  /** Adds findings from another package as they are, such as those a plugin made inside a file. */
124
138
  push(findings: DefuseFinding[]): void;
125
139
  /** Reports a link, classified by pdf-defuse's checks of its target. */
package/dist/context.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ZipContext = exports.PlanContext = exports.RunContext = exports.clip = void 0;
4
4
  const links_1 = require("./links");
5
5
  const plan_1 = require("./plan");
6
+ const score_1 = require("./score");
6
7
  const types_1 = require("./types");
7
8
  const xml_1 = require("./xml");
8
9
  const OLE_SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
@@ -12,6 +13,8 @@ const clip = (raw, n = 200) => {
12
13
  return s.length > n ? `${s.slice(0, n - 3)}...` : s;
13
14
  };
14
15
  exports.clip = clip;
16
+ /** A macro of the document's own project, by name: Macro, Module.Macro, or Excel's [0]!Macro for this workbook. */
17
+ const OWN_MACRO = /^(?:\[0\]!)?[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)?$/u;
15
18
  /** What one inspection holds while the format rules run. */
16
19
  class RunContext {
17
20
  factory;
@@ -51,7 +54,7 @@ class RunContext {
51
54
  }
52
55
  /**
53
56
  * Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. `action` replaces the
54
- * action the overrides give, for content whose fate a plugin decided.
57
+ * action the overrides give, for content whose fate a plugin decided. Such a finding as info weighs nothing.
55
58
  */
56
59
  report(category, detail, location, data, action) {
57
60
  const act = action ?? this.factory.actionFor(category, detail);
@@ -64,7 +67,7 @@ class RunContext {
64
67
  const finding = this.factory.make(category, detail, location, data);
65
68
  finding.action = act;
66
69
  if (action === 'info')
67
- this.run.superseded.add(finding);
70
+ (0, score_1.pluginDecided)(finding);
68
71
  this.shared.findings.push(finding);
69
72
  return { action: act, finding };
70
73
  }
@@ -76,17 +79,39 @@ class RunContext {
76
79
  const finding = this.factory.make(category, detail, 'additional occurrences', counted);
77
80
  finding.action = act;
78
81
  if (action === 'info')
79
- this.run.superseded.add(finding);
82
+ (0, score_1.pluginDecided)(finding);
80
83
  this.shared.findings.push(finding);
81
84
  }
82
85
  counted.count = Number(counted.count) + 1;
83
86
  return { action: act };
84
87
  }
88
+ /**
89
+ * Reports a link, callback or field that runs the macro `name`. When the script plugins answered for the document's
90
+ * own VBA project, a trigger of one of its macros follows their answer: it stays, as info, when they kept the
91
+ * project, and the overrides decide it when they removed the project. Either way it weighs nothing, since the
92
+ * plugins scored the macro it runs.
93
+ */
94
+ macroTrigger(category, detail, location, data, name) {
95
+ const own = name !== undefined && this.run.macrosAnswered === true && OWN_MACRO.test(name.trim());
96
+ const r = this.report(category, detail, location, data, own && this.run.macrosKept ? 'info' : undefined);
97
+ if (own && r.finding)
98
+ (0, score_1.pluginDecided)(r.finding);
99
+ return r;
100
+ }
85
101
  /** Reports content that goes whatever the overrides say: its action is strip, or reject when the caller asked for that. */
86
102
  removal(category, detail, location, data) {
87
103
  const act = this.factory.actionFor(category, detail);
88
104
  return this.report(category, detail, location, data, act === 'info' ? 'strip' : act);
89
105
  }
106
+ /**
107
+ * Takes back a finding reported before a plugin answered for its content, whose answer is final. A finding past the
108
+ * first 200 of its kind was only counted, so there is none to take back.
109
+ */
110
+ retract(finding) {
111
+ const i = finding ? this.shared.findings.indexOf(finding) : -1;
112
+ if (i >= 0)
113
+ this.shared.findings.splice(i, 1);
114
+ }
90
115
  /** Adds findings from another package as they are, such as those a plugin made inside a file. */
91
116
  push(findings) {
92
117
  this.shared.findings.push(...findings);
package/dist/engine.js CHANGED
@@ -59,21 +59,21 @@ const xmlLimitsOf = (limits) => ({
59
59
  entityExpansions: limits.xmlEntityExpansions,
60
60
  entitySize: limits.xmlEntitySize,
61
61
  });
62
- const PLUGIN_KEPT = new Set([types_1.OfficeDetail.PluginPassed, types_1.OfficeDetail.PluginScrubbed]);
63
62
  /**
64
63
  * `incomplete` marks a run that stopped before it read the whole package. It is rejected without a score, whatever
65
64
  * the actions say, because an override can make the finding that stopped it look harmless. In the check of an output,
66
65
  * `isOutput`, the files plugins kept count in the score; in an upload they do not, and a file a plugin cleaned makes
67
66
  * the upload strippable, since the output must be written.
68
67
  */
69
- function finish(findings, options, incomplete, format, isOutput = false, superseded) {
68
+ function finish(findings, options, incomplete, format, isOutput = false) {
70
69
  const named = { format: format?.format ?? '', mimeType: format?.mimeType ?? '' };
71
70
  if (incomplete)
72
71
  return { status: 'rejected', score: null, risk: types_1.OfficeRisk.Unknown, findings, ...named };
73
72
  const rejected = findings.some(f => f.action === 'reject');
74
73
  const strippable = findings.some(f => f.action === 'strip' || (!isOutput && f.detail === types_1.OfficeDetail.PluginScrubbed));
75
- const scored = findings.filter(f => !superseded?.has(f) && (isOutput || !PLUGIN_KEPT.has(f.detail)));
76
- const { score, risk } = (0, score_1.scoreFindings)(scored, options);
74
+ // What a plugin decided counts at the weights of the plugin's own findings; the PLUGIN_* findings that record its
75
+ // answer weigh nothing.
76
+ const { score, risk } = (0, score_1.scoreFindings)(findings, options);
77
77
  return { status: rejected ? 'rejected' : strippable ? 'strippable' : 'clean', score, risk, findings, ...named };
78
78
  }
79
79
  /**
@@ -167,7 +167,6 @@ function newRun(options, depth, deadline, verify) {
167
167
  verify,
168
168
  kept: new Map(),
169
169
  held: [],
170
- superseded: new WeakSet(),
171
170
  sources: [],
172
171
  };
173
172
  }
@@ -246,7 +245,7 @@ async function analyze(run, source) {
246
245
  findings.push(...ctx.findings);
247
246
  if (run.temp.ioError !== undefined)
248
247
  throw run.temp.ioError;
249
- return finish(findings, options, false, ctx.format, isOutput, run.superseded);
248
+ return finish(findings, options, false, ctx.format, isOutput);
250
249
  }
251
250
  catch (e) {
252
251
  if (src.failure !== undefined)
@@ -401,6 +400,8 @@ async function disarmAtDepth(source, sink, options, depth, deadline = options.li
401
400
  }
402
401
  // The output is checked with the caller's overrides and limits, and the plugins' verdicts in place of the plugins.
403
402
  const verifyOptions = {
403
+ // No plugin is asked again. An empty list still reads VBA project parts the way the upload's run did.
404
+ scriptPlugins: options.scriptPlugins && [],
404
405
  actionOverrides: options.actionOverrides,
405
406
  scoreWeights: options.scoreWeights,
406
407
  scoreBands: options.scoreBands,
package/dist/fields.js CHANGED
@@ -29,7 +29,7 @@ function evaluateField(ctx, location, code, computed, result = '') {
29
29
  if (name === 'DDE' || name === 'DDEAUTO')
30
30
  actions.push(ctx.report(types_1.OfficeCategory.Dde, types_1.OfficeDetail.Field, location, data).action);
31
31
  else if (name === 'MACROBUTTON')
32
- actions.push(ctx.report(types_1.OfficeCategory.Field, types_1.OfficeDetail.Macrobutton, location, { ...data, macro: (0, context_1.clip)(words[1]?.text ?? '') }).action);
32
+ actions.push(ctx.macroTrigger(types_1.OfficeCategory.Field, types_1.OfficeDetail.Macrobutton, location, { ...data, macro: (0, context_1.clip)(words[1]?.text ?? '') }, words[1]?.text ?? '').action);
33
33
  else if (name === 'INCLUDETEXT' || name === 'INCLUDEPICTURE' || name === 'IMPORT' || name === 'LINK') {
34
34
  const target = arg(name === 'LINK' ? 2 : 1);
35
35
  if (target === undefined)
package/dist/findings.js CHANGED
@@ -75,6 +75,10 @@ def(types_1.OfficeCategory.Encrypted, types_1.OfficeDetail.XorObfuscation, 'info
75
75
  def(types_1.OfficeCategory.Encrypted, types_1.OfficeDetail.UnsupportedAlgorithm, 'reject', 0, 'The file is encrypted with an algorithm office-defuse does not decrypt<: {algorithm}|>');
76
76
  // MACRO
77
77
  def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, 'strip', 70, 'The document holds VBA macros');
78
+ def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.CompiledCode, 'strip', 10, "The VBA project's compiled code was removed, so Office compiles the source the plugins checked");
79
+ def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.PluginPassed, 'info', 0, 'A VBA module was kept by <plugin {plugin}|a plugin>');
80
+ def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.PluginRemoved, 'strip', 0, '<Plugin {plugin}|A plugin> did not keep a VBA module, so the VBA project was removed');
81
+ def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.PluginFailed, 'strip', 0, '<Plugin {plugin}|A plugin> failed on a VBA module, so the VBA project was removed');
78
82
  def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, 'strip', 70, 'The workbook holds an Excel 4.0 macro sheet');
79
83
  def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.WordBasic, 'strip', 70, 'The Word 6.0 or 95 document holds WordBasic macros, which Word runs as VBA');
80
84
  def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.OdfBasic, 'strip', 70, 'The document holds Basic macros');
@@ -133,12 +137,14 @@ def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Applet, 'strip', 60, '
133
137
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Plugin, 'strip', 50, 'An embedded browser plug-in object');
134
138
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Equation, 'strip', 70, 'An Equation Editor 3.0 object, which CVE-2017-11882 and CVE-2018-0802 exploit');
135
139
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Attachment, 'strip', 30, '<An attached file "{name}"|An attached file> (<{type}|unknown type>)');
136
- def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginPassed, 'info', 20, 'An embedded file was kept by <plugin {plugin}|a plugin> with nothing removed');
137
- def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginScrubbed, 'info', 10, 'An embedded file was cleaned and kept by <plugin {plugin}|a plugin>');
140
+ def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginPassed, 'info', 0, 'An embedded file was kept by <plugin {plugin}|a plugin> with nothing removed');
141
+ def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginScrubbed, 'info', 0, 'An embedded file was cleaned and kept by <plugin {plugin}|a plugin>');
138
142
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginFailed, 'strip', 0, '<Plugin {plugin}|A plugin> failed, so the embedded file was removed');
139
143
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginRemoved, 'strip', 0, 'An embedded file was removed by <plugin {plugin}|a plugin>');
140
144
  // CONTROL
141
- def(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, 'strip', 50, 'An ActiveX control');
145
+ def(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, 'strip', 50, 'An ActiveX control<: {reason}|>');
146
+ def(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, 'info', 10, 'A Forms 2.0 control<, {className}|>, whose persisted properties office-defuse read');
147
+ def(types_1.OfficeCategory.Control, types_1.OfficeDetail.ControlPicture, 'strip', 10, "A control's picture for printing, a Windows metafile");
142
148
  // PROCESSING
143
149
  def(types_1.OfficeCategory.Processing, types_1.OfficeDetail.VerificationFailed, 'reject', 0, 'The defused output did not pass its own inspection, so nothing was written');
144
150
  /** The built-in spec for a kind of finding, or undefined for a kind office-defuse does not make, such as one from another package. */
package/dist/flat.js CHANGED
@@ -281,13 +281,10 @@ async function decideBinaries(ctx, odf) {
281
281
  const object = plan.objects.find(o => o.index === parent.index);
282
282
  if (object) {
283
283
  decided.add(parent.index);
284
- if (decision?.keep) {
285
- if (object.finding) {
286
- object.finding.action = 'info';
287
- ctx.run.superseded.add(object.finding);
288
- }
289
- }
290
- else if (decision || object.strips)
284
+ // A plugin's answer on the object's file is final, so the object's own finding is taken back.
285
+ if (decision?.answered)
286
+ ctx.retract(object.finding);
287
+ if (!decision?.keep && (decision || object.strips))
291
288
  plan.element(exports.FLAT_ODF_PART, parent.index, { remove: true });
292
289
  }
293
290
  else if (decision && !decision.keep)