@patrickdk77/office-defuse 0.2.4 → 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
@@ -285,9 +285,11 @@ A file comes as a `ContainedFile`. `name` is the part or entry name, a Packager
285
285
  | `removed` | goes, with every reference to it | EMBEDDED/PLUGIN_REMOVED, `strip` |
286
286
  | `scrubbed` with nothing written, or an error | goes, with every reference to it | EMBEDDED/PLUGIN_FAILED, `strip` |
287
287
 
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. 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.
289
289
 
290
- 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.
291
293
 
292
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.
293
295
 
@@ -314,11 +316,13 @@ await disarmOffice(bytes, { filePlugins: [officePlugin()] });
314
316
 
315
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.
316
318
 
317
- 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, and MACRO/VBA_PROJECT turns to `info`.
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.
318
322
 
319
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.
320
324
 
321
- 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.
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.
322
326
 
323
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.
324
328
 
@@ -453,6 +457,8 @@ Three combinations add more. Code here means macros or DDE. Code or a run-progra
453
457
 
454
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.
455
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
+
456
462
  ## Options
457
463
 
458
464
  | Option | Default | Purpose |
@@ -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
  }
@@ -43,9 +48,9 @@ export declare function pushContained(ctx: RunContext, found: DefuseFinding[] |
43
48
  */
44
49
  export declare function decideContained(ctx: RunContext, inner: InnerFile): Promise<ContainedDecision | undefined>;
45
50
  /**
46
- * The rule a file gets for being there, such as EMBEDDED/OLE_OBJECT, applies when no plugin took the file. A plugin
47
- * 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.
48
53
  */
49
- 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;
50
55
  /** Bytes in a spill sink the run holds until it ends, as the rewrite takes a plugin's output. */
51
56
  export declare function spillOf(run: RunState, bytes: Uint8Array): Promise<SpillSink>;
package/dist/contained.js CHANGED
@@ -4,7 +4,7 @@ exports.RUN_OPTIONS = void 0;
4
4
  exports.passOn = passOn;
5
5
  exports.pushContained = pushContained;
6
6
  exports.decideContained = decideContained;
7
- exports.presence = presence;
7
+ exports.ownFinding = ownFinding;
8
8
  exports.spillOf = spillOf;
9
9
  const errors_1 = require("./errors");
10
10
  const io_1 = require("./io");
@@ -65,7 +65,7 @@ async function decideContained(ctx, inner) {
65
65
  if (expected) {
66
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 ?? '' });
67
67
  pushContained(ctx, expected.findings, inner.location, name);
68
- return { keep: true };
68
+ return { keep: true, answered: true };
69
69
  }
70
70
  if (!plugins.length)
71
71
  return undefined;
@@ -108,6 +108,7 @@ async function decideContained(ctx, inner) {
108
108
  }
109
109
  else
110
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';
111
112
  pushContained(ctx, r.findings, inner.location, name);
112
113
  const replacement = keep && out.length > 0 ? out : undefined;
113
114
  if (replacement) {
@@ -117,7 +118,7 @@ async function decideContained(ctx, inner) {
117
118
  }
118
119
  else
119
120
  await out.dispose();
120
- return { keep, replacement };
121
+ return { keep, replacement, answered };
121
122
  }
122
123
  catch (e) {
123
124
  await out.dispose();
@@ -137,11 +138,13 @@ async function decideContained(ctx, inner) {
137
138
  }
138
139
  }
139
140
  /**
140
- * The rule a file gets for being there, such as EMBEDDED/OLE_OBJECT, applies when no plugin took the file. A plugin
141
- * 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.
142
143
  */
143
- function presence(decision) {
144
- 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);
145
148
  }
146
149
  /** Bytes in a spill sink the run holds until it ends, as the rewrite takes a plugin's output. */
147
150
  async function spillOf(run, bytes) {
package/dist/context.d.ts CHANGED
@@ -68,11 +68,6 @@ 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. */
@@ -82,12 +77,9 @@ export interface RunState {
82
77
  * that run one of its macros by name then stay too.
83
78
  */
84
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;
85
82
  }
86
- /**
87
- * 'info' for a link or callback that runs one of the document's own macros when its project stays, so the finding
88
- * reports it without removing it. Undefined otherwise, leaving the action to the overrides.
89
- */
90
- export declare function ownMacro(run: RunState, name: string): 'info' | undefined;
91
83
  /** A finding the rules made, with its action, which the rewrite reads. Past 200 of one kind, `finding` is absent. */
92
84
  export interface Reported {
93
85
  action: OfficeFindingAction;
@@ -125,11 +117,23 @@ export declare class RunContext {
125
117
  withPlan(): PlanContext;
126
118
  /**
127
119
  * Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. `action` replaces the
128
- * 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.
129
121
  */
130
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;
131
130
  /** Reports content that goes whatever the overrides say: its action is strip, or reject when the caller asked for that. */
132
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;
133
137
  /** Adds findings from another package as they are, such as those a plugin made inside a file. */
134
138
  push(findings: DefuseFinding[]): void;
135
139
  /** Reports a link, classified by pdf-defuse's checks of its target. */
package/dist/context.js CHANGED
@@ -1,9 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ZipContext = exports.PlanContext = exports.RunContext = exports.clip = void 0;
4
- exports.ownMacro = ownMacro;
5
4
  const links_1 = require("./links");
6
5
  const plan_1 = require("./plan");
6
+ const score_1 = require("./score");
7
7
  const types_1 = require("./types");
8
8
  const xml_1 = require("./xml");
9
9
  const OLE_SIGNATURE = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
@@ -15,13 +15,6 @@ const clip = (raw, n = 200) => {
15
15
  exports.clip = clip;
16
16
  /** A macro of the document's own project, by name: Macro, Module.Macro, or Excel's [0]!Macro for this workbook. */
17
17
  const OWN_MACRO = /^(?:\[0\]!)?[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)?$/u;
18
- /**
19
- * 'info' for a link or callback that runs one of the document's own macros when its project stays, so the finding
20
- * reports it without removing it. Undefined otherwise, leaving the action to the overrides.
21
- */
22
- function ownMacro(run, name) {
23
- return run.macrosKept && OWN_MACRO.test(name.trim()) ? 'info' : undefined;
24
- }
25
18
  /** What one inspection holds while the format rules run. */
26
19
  class RunContext {
27
20
  factory;
@@ -61,7 +54,7 @@ class RunContext {
61
54
  }
62
55
  /**
63
56
  * Records a finding. Past 200 of one kind, as in pdf-defuse, one more finding counts the rest. `action` replaces the
64
- * 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.
65
58
  */
66
59
  report(category, detail, location, data, action) {
67
60
  const act = action ?? this.factory.actionFor(category, detail);
@@ -74,7 +67,7 @@ class RunContext {
74
67
  const finding = this.factory.make(category, detail, location, data);
75
68
  finding.action = act;
76
69
  if (action === 'info')
77
- this.run.superseded.add(finding);
70
+ (0, score_1.pluginDecided)(finding);
78
71
  this.shared.findings.push(finding);
79
72
  return { action: act, finding };
80
73
  }
@@ -86,17 +79,39 @@ class RunContext {
86
79
  const finding = this.factory.make(category, detail, 'additional occurrences', counted);
87
80
  finding.action = act;
88
81
  if (action === 'info')
89
- this.run.superseded.add(finding);
82
+ (0, score_1.pluginDecided)(finding);
90
83
  this.shared.findings.push(finding);
91
84
  }
92
85
  counted.count = Number(counted.count) + 1;
93
86
  return { action: act };
94
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
+ }
95
101
  /** Reports content that goes whatever the overrides say: its action is strip, or reject when the caller asked for that. */
96
102
  removal(category, detail, location, data) {
97
103
  const act = this.factory.actionFor(category, detail);
98
104
  return this.report(category, detail, location, data, act === 'info' ? 'strip' : act);
99
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
+ }
100
115
  /** Adds findings from another package as they are, such as those a plugin made inside a file. */
101
116
  push(findings) {
102
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)
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 ?? '') }, (0, context_1.ownMacro)(ctx.run, 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
@@ -76,7 +76,7 @@ def(types_1.OfficeCategory.Encrypted, types_1.OfficeDetail.UnsupportedAlgorithm,
76
76
  // MACRO
77
77
  def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, 'strip', 70, 'The document holds VBA macros');
78
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', 30, 'A VBA module was kept by <plugin {plugin}|a plugin>');
79
+ def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.PluginPassed, 'info', 0, 'A VBA module was kept by <plugin {plugin}|a plugin>');
80
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
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');
82
82
  def(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, 'strip', 70, 'The workbook holds an Excel 4.0 macro sheet');
@@ -137,8 +137,8 @@ def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Applet, 'strip', 60, '
137
137
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Plugin, 'strip', 50, 'An embedded browser plug-in object');
138
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');
139
139
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Attachment, 'strip', 30, '<An attached file "{name}"|An attached file> (<{type}|unknown type>)');
140
- def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginPassed, 'info', 20, 'An embedded file was kept by <plugin {plugin}|a plugin> with nothing removed');
141
- 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>');
142
142
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginFailed, 'strip', 0, '<Plugin {plugin}|A plugin> failed, so the embedded file was removed');
143
143
  def(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.PluginRemoved, 'strip', 0, 'An embedded file was removed by <plugin {plugin}|a plugin>');
144
144
  // CONTROL
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)
package/dist/macros.d.ts CHANGED
@@ -8,6 +8,11 @@ export interface VbaDecision {
8
8
  reason?: string;
9
9
  /** The designer storages of the project's user forms, which stay with it and get no object rules of their own. */
10
10
  designers?: CfbEntry[];
11
+ /**
12
+ * A plugin passed or removed every module, and the project stays or goes by those answers alone. They are final:
13
+ * the report holds them, and nothing of office-defuse's own about the project.
14
+ */
15
+ answered?: boolean;
11
16
  }
12
17
  /**
13
18
  * Shows each module of the VBA project in `vba` to the script plugins. The first plugin that accepts a module decides
package/dist/macros.js CHANGED
@@ -41,12 +41,14 @@ async function decideVba(ctx, cfb, vba, location, plan) {
41
41
  if (!(await (0, vba_1.isPurged)(cfb, project)))
42
42
  ctx.removal(types_1.OfficeCategory.Macro, types_1.OfficeDetail.CompiledCode, location, { storage: location });
43
43
  run.macrosKept = true;
44
- return { keep: true, designers };
44
+ run.macrosAnswered = true;
45
+ return { keep: true, designers, answered: true };
45
46
  }
46
47
  if (!plugins.length)
47
48
  return { keep: false };
48
49
  const start = ctx.findings.length;
49
50
  let keep = project.modules.length > 0;
51
+ let answered = keep;
50
52
  const used = new Set();
51
53
  for (const [i, m] of project.modules.entries()) {
52
54
  const at = `${location}/${(0, cfb_1.showName)(m.stream.name)}`;
@@ -78,6 +80,8 @@ async function decideVba(ctx, cfb, vba, location, plan) {
78
80
  ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.PluginPassed, at, data);
79
81
  else {
80
82
  keep = false;
83
+ if (r.result !== 'removed')
84
+ answered = false;
81
85
  ctx.removal(types_1.OfficeCategory.Macro, types_1.OfficeDetail.PluginRemoved, at, data);
82
86
  }
83
87
  pushScript(ctx, r.findings);
@@ -85,15 +89,21 @@ async function decideVba(ctx, cfb, vba, location, plan) {
85
89
  catch (e) {
86
90
  (0, contained_1.passOn)(run, e);
87
91
  keep = false;
92
+ answered = false;
88
93
  ctx.removal(types_1.OfficeCategory.Macro, types_1.OfficeDetail.PluginFailed, at, data);
89
94
  }
90
95
  break;
91
96
  }
92
- if (!decided)
97
+ if (!decided) {
93
98
  keep = false;
99
+ answered = false;
100
+ }
101
+ }
102
+ if (!keep) {
103
+ if (answered)
104
+ run.macrosAnswered = true;
105
+ return { keep: false, answered };
94
106
  }
95
- if (!keep)
96
- return { keep: false };
97
107
  const passed = { result: 'passed', plugin: [...used].join(', '), findings: ctx.findings.slice(start).map(f => ({ ...f, data: f.data && { ...f.data } })) };
98
108
  const designers = await formDesigners(ctx, cfb, project, location);
99
109
  if (!designers)
@@ -117,7 +127,8 @@ async function decideVba(ctx, cfb, vba, location, plan) {
117
127
  ctx.removal(types_1.OfficeCategory.Macro, types_1.OfficeDetail.CompiledCode, location, { storage: location });
118
128
  }
119
129
  run.macrosKept = true;
120
- return { keep: true, designers };
130
+ run.macrosAnswered = true;
131
+ return { keep: true, designers, answered: true };
121
132
  }
122
133
  /**
123
134
  * The designer storages of the project's user forms, beside its VBA storage, named as their modules. Undefined when
package/dist/msg.js CHANGED
@@ -231,7 +231,7 @@ async function attachment(ctx, cfb, data, file, info, plan) {
231
231
  const decision = await (0, contained_1.decideContained)(ctx, { ...file, chunks: () => (0, io_1.chunksOf)(source, data.size) });
232
232
  if (decision?.replacement)
233
233
  plan?.replace.set(data, decision.replacement);
234
- const r = ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Attachment, file.location, { ...info, size: data.size }, (0, contained_1.presence)(decision));
234
+ const r = (0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Attachment, file.location, { ...info, size: data.size });
235
235
  return decision && !decision.keep ? { ...r, action: 'strip' } : r;
236
236
  }
237
237
  /** The fixed-size values of a property stream (MS-OXMSG 2.4.2), by property id. An attachment's has an 8-byte header. */
package/dist/objects.js CHANGED
@@ -66,8 +66,17 @@ async function compoundRules(ctx, cfb, prefix, rootIsObject, plan, rootDecision)
66
66
  if (r.action === 'strip' && e.kind !== 'root')
67
67
  plan?.drop.add(e);
68
68
  };
69
+ /** Objects a plugin answered for. Its answer is final, so nothing inside them gets the rules here. */
70
+ const answered = new Set();
71
+ const inAnswered = (e) => {
72
+ for (let p = e.parent; p; p = p.parent)
73
+ if (answered.has(p))
74
+ return true;
75
+ return false;
76
+ };
77
+ // A storage comes after the storage that holds it, so an object is decided before what it holds.
69
78
  for (const e of cfb.entries) {
70
- if (e.kind === 'stream' || inDesigner(e))
79
+ if (e.kind === 'stream' || inDesigner(e) || inAnswered(e))
71
80
  continue;
72
81
  const child = (name) => e.children.find(c => upper(c) === name);
73
82
  const names = new Set(e.children.map(upper));
@@ -77,8 +86,16 @@ async function compoundRules(ctx, cfb, prefix, rootIsObject, plan, rootDecision)
77
86
  : [...names].some(n => OBJECT_STREAMS.has(n) || CONTROL_STREAMS.has(n)) ||
78
87
  (parent === 'OBJECTPOOL' && e.parent?.parent?.kind === 'root') ||
79
88
  (e.parent?.kind === 'root' && /^MBD[0-9A-F]{8}$/i.test(e.name));
80
- if (isObject)
89
+ if (isObject) {
81
90
  objects.add(e);
91
+ const decided = await objectRules(ctx, cfb, e, at(prefix, e), plan, e.kind === 'root' ? rootDecision : undefined);
92
+ for (const r of decided.found)
93
+ drop(r, e);
94
+ if (decided.answered) {
95
+ answered.add(e);
96
+ continue;
97
+ }
98
+ }
82
99
  // A VBA project: a VBA storage with a dir stream, which names the modules (MS-OVBA 2.2). The storage that holds
83
100
  // the project goes, or the VBA storage when that is the root. With script plugins, the project stays when they
84
101
  // pass every module and the rewrite can take out its compiled code: in the file the rewrite writes, and outside any
@@ -91,15 +108,16 @@ async function compoundRules(ctx, cfb, prefix, rootIsObject, plan, rootDecision)
91
108
  if (decision?.keep)
92
109
  for (const d of decision.designers ?? [])
93
110
  designers.add(d);
94
- drop(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, at(prefix, vba), data, decision?.keep ? 'info' : undefined), e.kind === 'root' ? vba : e);
111
+ // The plugins' answers are final: the report holds them, and nothing of office-defuse's own about the project.
112
+ if (decision?.answered)
113
+ drop({ action: decision.keep ? 'info' : 'strip' }, e.kind === 'root' ? vba : e);
114
+ else
115
+ drop(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, at(prefix, vba), data), e.kind === 'root' ? vba : e);
95
116
  }
96
117
  if (e.kind === 'storage' && (upper(e) === '_VBA_PROJECT_CUR' || upper(e) === 'MACROS') && e.children.length && !projects.has(e)) {
97
118
  // Excel's and Word's project storages, without the dir stream a whole project has.
98
119
  drop(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, at(prefix, e), { storage: at(prefix, e) }), e);
99
120
  }
100
- if (isObject)
101
- for (const r of await objectRules(ctx, cfb, e, at(prefix, e), plan, e.kind === 'root' ? rootDecision : undefined))
102
- drop(r, e);
103
121
  }
104
122
  }
105
123
  /** Reads an OLE object's storage: its class, its CompObj and OLE streams, and Packager's native data. */
@@ -186,6 +204,7 @@ async function objectFile(ctx, cfb, e, d, location, plan) {
186
204
  * One OLE object's storage: a control, a linked object, or an embedded one, with what it holds. Returns its findings,
187
205
  * and a strip when a plugin removed the object. In the file the rewrite writes, `plan` is set and an embedded object
188
206
  * goes to the plugins first. `given` is the verdict on an object whose container showed it to the plugins already.
207
+ * `answered` says a plugin answered for the object, so nothing else here looks at it or at what it holds.
189
208
  */
190
209
  async function objectRules(ctx, cfb, e, location, plan, given) {
191
210
  const d = await describeObject(cfb, e, location);
@@ -203,11 +222,11 @@ async function objectRules(ctx, cfb, e, location, plan, given) {
203
222
  // a metafile beside them, goes on its own.
204
223
  const problem = await (0, forms_1.controlStorageProblem)(cfb, e, forms_1.WORD_CONTROL_STREAMS);
205
224
  if (problem)
206
- return [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, { ...data, reason: (0, context_1.clip)(problem) })];
225
+ return { found: [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, { ...data, reason: (0, context_1.clip)(problem) })], answered: false };
207
226
  const print = e.children.find(c => c.kind === 'stream' && c.name === '\x03PRINT');
208
227
  if (print && ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ControlPicture, `${location}/${(0, cfb_1.showName)(print.name)}`, { storage: location, bytes: print.size }).action === 'strip')
209
228
  plan?.drop.add(print);
210
- return [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, location, data)];
229
+ return { found: [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, location, data)], answered: false };
211
230
  }
212
231
  const found = [];
213
232
  if (d.pkg) {
@@ -219,13 +238,15 @@ async function objectRules(ctx, cfb, e, location, plan, given) {
219
238
  found.push(linkedObject(ctx, d, location, { storage: location, ...(d.clsid ? { clsid: d.clsid } : {}) }));
220
239
  else {
221
240
  const decision = given ?? (plan ? await objectFile(ctx, cfb, e, d, location, plan) : undefined);
222
- found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, location, data, (0, contained_1.presence)(decision)));
241
+ if (decision?.answered)
242
+ return { found: [...found, { action: decision.keep ? 'info' : 'strip' }], answered: true };
243
+ found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, location, data));
223
244
  if (decision && !decision.keep)
224
245
  found.push({ action: 'strip' });
225
246
  }
226
247
  if (d.equation)
227
248
  found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Equation, location, data));
228
- return found;
249
+ return { found, answered: false };
229
250
  }
230
251
  /**
231
252
  * Reports a linked object. A URL moniker's target loads from outside the file whatever its text looks like: a
package/dist/odf.js CHANGED
@@ -376,14 +376,14 @@ async function inspectOdf(ctx) {
376
376
  const key = (0, zip_1.nameKey)(e.name);
377
377
  const decision = await (0, contained_1.decideContained)(ctx, { name: e.name, location: e.name, chunks: () => zip.read(e) });
378
378
  if (decision) {
379
- decided.set(key, decision.keep);
379
+ decided.set(key, decision);
380
380
  if (!decision.keep)
381
381
  plan.drop.add(key);
382
382
  else if (decision.replacement)
383
383
  plan.replace.set(key, decision.replacement);
384
384
  }
385
385
  if (!oleRefs.get(key)) {
386
- const r = ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleFile, e.name, { part: e.name }, (0, contained_1.presence)(decision));
386
+ const r = (0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleFile, e.name, { part: e.name });
387
387
  if (!decision && r.action === 'strip')
388
388
  plan.drop.add(key);
389
389
  }
@@ -398,15 +398,14 @@ async function inspectOdf(ctx) {
398
398
  }
399
399
  const files = new Set(zip.entries.filter(e => !e.directory).map(e => (0, zip_1.nameKey)(e.name)));
400
400
  for (const o of plan.objects) {
401
- const kept = decided.get(o.target);
402
- if (kept === true) {
403
- // A plugin kept the object's file, so the object stays.
404
- if (o.finding) {
405
- o.finding.action = 'info';
406
- ctx.run.superseded.add(o.finding);
407
- }
401
+ const decision = decided.get(o.target);
402
+ // A plugin's answer on the object's file is final, so the object's own finding is taken back.
403
+ if (decision?.answered)
404
+ ctx.retract(o.finding);
405
+ const kept = decision?.keep;
406
+ // A plugin kept the object's file, so the object stays.
407
+ if (kept === true)
408
408
  continue;
409
- }
410
409
  if (kept === false || o.strips) {
411
410
  plan.element(o.part, o.index, { remove: true });
412
411
  if (files.has(o.target))
package/dist/ooxml.js CHANGED
@@ -386,7 +386,7 @@ class PartRules {
386
386
  if (a.ns !== '' || !a.value.trim() || !/^(on[A-Z]|get[A-Z]|loadImage$)/.test(a.local))
387
387
  continue;
388
388
  // The callback goes and the control stays, doing nothing, unless it calls a macro of a project that stays.
389
- if (this.ctx.report(types_1.OfficeCategory.Ui, types_1.OfficeDetail.Callback, this.part, { callback: `${a.local}=${(0, context_1.clip)(a.value)}` }, (0, context_1.ownMacro)(this.ctx.run, a.value)).action === 'strip') {
389
+ if (this.ctx.macroTrigger(types_1.OfficeCategory.Ui, types_1.OfficeDetail.Callback, this.part, { callback: `${a.local}=${(0, context_1.clip)(a.value)}` }, a.value).action === 'strip') {
390
390
  this.ctx.plan.element(this.part, el.index, { strip: new Set([(0, plan_1.attributeKey)('', a.local)]) });
391
391
  }
392
392
  }
@@ -419,7 +419,7 @@ class PartRules {
419
419
  else if (XDR_NS.has(ns)) {
420
420
  const macro = (0, xml_1.attr)(el, '', 'macro');
421
421
  // The shape stays, and clicking it runs nothing.
422
- if (macro?.trim() && this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }, (0, context_1.ownMacro)(this.ctx.run, macro)).action === 'strip') {
422
+ if (macro?.trim() && this.ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }, macro).action === 'strip') {
423
423
  this.ctx.plan.element(this.part, el.index, { strip: new Set([(0, plan_1.attributeKey)('', 'macro')]) });
424
424
  }
425
425
  }
@@ -478,7 +478,7 @@ class PartRules {
478
478
  if (ns === VML_EXCEL_NS && el.local === 'FmlaMacro' && this.macroText) {
479
479
  const macro = this.macroText.shown.text.trim();
480
480
  if (this.macroText.shown.content)
481
- this.remove(this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }, (0, context_1.ownMacro)(this.ctx.run, macro)), this.macroText);
481
+ this.remove(this.ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(macro) }, macro), this.macroText);
482
482
  this.macroText = undefined;
483
483
  }
484
484
  if (this.hyperlinkBase && EXTENDED_NS.has(ns) && el.local === 'HyperlinkBase') {
@@ -519,7 +519,7 @@ class PartRules {
519
519
  this.remove(this.ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.RunProgram, this.part, data), el);
520
520
  else if (action.startsWith('ppaction://macro')) {
521
521
  const name = /[?&]name=([^&]*)/i.exec(written)?.[1] ?? '';
522
- this.remove(this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, data, (0, context_1.ownMacro)(this.ctx.run, safeDecode(name))), el);
522
+ this.remove(this.ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, data, safeDecode(name)), el);
523
523
  }
524
524
  else if (action.startsWith('ppaction://ole'))
525
525
  this.remove(this.ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.OleVerb, this.part, data), el);
@@ -754,25 +754,25 @@ async function partRules(ctx, p) {
754
754
  // With script plugins, a VBA project part is read here, so its modules go to them, rather than to the file plugins.
755
755
  const vbaDecision = p.vba ?? (vba && ctx.run.options.scriptPlugins !== undefined ? await vbaPart(ctx, p.entry, loc) : undefined);
756
756
  const decision = vbaDecision ?? (contained ? await (0, contained_1.decideContained)(ctx, { name: loc, declaredType: ct || undefined, location: loc, chunks: () => ctx.zip.read(p.entry) }) : undefined);
757
- const act = (0, contained_1.presence)(decision);
758
757
  const found = [];
758
+ // A plugin's answer is final, so a file it answered for gets none of office-defuse's own findings.
759
759
  if (vba && !vbaDecision)
760
- found.push(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, data, act));
760
+ found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, data));
761
761
  // A macro sheet becomes an empty worksheet, so the indexes of the sheets after it stay right.
762
762
  if ((exports.TYPE.macrosheet.has(ct) || as(AS.macrosheet)) && ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, loc, data).action === 'strip')
763
763
  ctx.plan.sheets.add(key);
764
764
  if (ole)
765
- found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, loc, data, act));
765
+ found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, loc, data));
766
766
  if (pkg)
767
- found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Package, loc, data, act));
767
+ found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Package, loc, data));
768
768
  if (control && p.forms && !p.forms.problem)
769
769
  found.push(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, loc, { ...data, clsid: p.forms.classId ?? '' }));
770
770
  else if (control || (controlBinary && !p.claimed)) {
771
771
  const why = control && p.forms ? { ...(p.forms.classId ? { clsid: p.forms.classId } : {}), ...(p.forms.problem ? { reason: (0, context_1.clip)(p.forms.problem) } : {}) } : {};
772
- found.push(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, { ...data, ...why }, control ? undefined : act));
772
+ found.push(control ? ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, { ...data, ...why }) : (0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, { ...data, ...why }));
773
773
  }
774
774
  if (p.ole && !vba && !ole && !control && !controlBinary && !pkg)
775
- found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleFile, loc, data, act));
775
+ found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleFile, loc, data));
776
776
  if (decision ? !decision.keep : found.some(f => f.action === 'strip'))
777
777
  ctx.plan.drop.add(key);
778
778
  else if (decision?.replacement)
package/dist/ppt.js CHANGED
@@ -332,7 +332,9 @@ async function storageRules(ctx, source, s, role, location, rewrite) {
332
332
  // An object's storage goes to the plugins as the compound file it is, and stays as they leave it.
333
333
  const size = await inner.size();
334
334
  const decision = await (0, contained_1.decideContained)(ctx, { location, chunks: () => (0, io_1.chunksOf)(inner, size) });
335
- await (0, objects_1.compoundRules)(ctx, cfb, location, true, undefined, decision);
335
+ // A plugin's answer is final, so the object's own rules do not run.
336
+ if (!decision?.answered)
337
+ await (0, objects_1.compoundRules)(ctx, cfb, location, true, undefined, decision);
336
338
  if ((decision && !decision.keep) || ctx.strips > strips)
337
339
  return 'remove';
338
340
  if (decision?.replacement) {
@@ -385,7 +387,7 @@ async function containerRules(ctx, reader, h, offset, kids, stream, edits, links
385
387
  r = ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.RunProgram, location, data);
386
388
  // A macro of a VBA project that stays keeps its action.
387
389
  else if (action === 1)
388
- r = ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, location, data, (0, context_1.ownMacro)(ctx.run, name));
390
+ r = ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, location, data, name);
389
391
  else if (action === 5)
390
392
  r = ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.OleVerb, location, data);
391
393
  if (r?.action === 'strip') {
package/dist/rtf.js CHANGED
@@ -670,7 +670,9 @@ async function objectData(ctx, o, bytes, location, base, cuts) {
670
670
  cuts.push({ start: o.objdata[0], end: o.objdata[1], text: objdataGroup((0, oleobj_1.replaceSized)(bytes, ole1.dataAt, native.length, newNative)) });
671
671
  }
672
672
  }
673
- const act = (0, contained_1.presence)(decision);
673
+ // A plugin's answer is final, so the object's own rules do not run.
674
+ if (decision?.answered)
675
+ return;
674
676
  if (pkg)
675
677
  Object.assign(data, (0, objects_1.packageData)(pkg));
676
678
  else if (native && native.length >= 8 && cfb_1.CFB_SIGNATURE.every((v, i) => native[i] === v)) {
@@ -707,7 +709,7 @@ async function objectData(ctx, o, bytes, location, base, cuts) {
707
709
  if (className.toLowerCase() === 'ole2link')
708
710
  ctx.report(types_1.OfficeCategory.External, types_1.OfficeDetail.Object, location, data);
709
711
  else
710
- ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, location, data, act);
712
+ ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, location, data);
711
713
  if (equation)
712
714
  ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Equation, location, data);
713
715
  }
package/dist/score.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { OfficeCategory as C, OfficeDetail as D, type DefuseFinding, type OfficeOptions, OfficeRisk, type OfficeScoreBands } from './types';
2
2
  export declare const DEFAULT_BANDS: OfficeScoreBands;
3
+ /** Marks a finding as one a plugin's answer decided. */
4
+ export declare function pluginDecided(f: DefuseFinding): void;
3
5
  interface Combination {
4
6
  name: string;
5
7
  weight: number;
package/dist/score.js CHANGED
@@ -1,12 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.COMBINATIONS = exports.DEFAULT_BANDS = void 0;
4
+ exports.pluginDecided = pluginDecided;
4
5
  exports.weightOf = weightOf;
5
6
  exports.scoreFindings = scoreFindings;
6
7
  exports.riskFor = riskFor;
7
8
  const findings_1 = require("./findings");
8
9
  const types_1 = require("./types");
9
10
  exports.DEFAULT_BANDS = { low: 1, medium: 25, high: 50, critical: 80 };
11
+ /**
12
+ * Findings whose fate a plugin's answer decided, such as a button that runs a macro the plugins kept. They weigh
13
+ * nothing, in this run's score and in the weights a container gets: the plugin's own findings score that content.
14
+ */
15
+ const decided = new WeakSet();
16
+ /** Marks a finding as one a plugin's answer decided. */
17
+ function pluginDecided(f) {
18
+ decided.add(f);
19
+ }
10
20
  /** Code that runs, in any of its forms. */
11
21
  const runsCode = (has) => has(types_1.OfficeCategory.Macro) || has(types_1.OfficeCategory.Dde);
12
22
  exports.COMBINATIONS = [
@@ -24,6 +34,8 @@ exports.COMBINATIONS = [
24
34
  ];
25
35
  /** The caller's weights first, then the built-in table. A kind from another package scores at the weight it carries. */
26
36
  function weightOf(f, options) {
37
+ if (decided.has(f))
38
+ return 0;
27
39
  const w = options.scoreWeights ?? [];
28
40
  const exact = w.find(x => x.category === f.category && x.detail === f.detail);
29
41
  if (exact)
package/dist/xls.js CHANGED
@@ -217,7 +217,7 @@ async function sheetRules(ctx, cfb, stream, s, decrypt, location, edits, control
217
217
  // A shape that runs one of the workbook's own macros stays when its VBA project does.
218
218
  const name = macro.ref && ownName ? ownName(macro.ref) : undefined;
219
219
  const data = { sheet: (0, context_1.clip)(s.name), ...(macro.ref && nameOf ? { macro: (0, context_1.clip)(nameOf(macro.ref) ?? '') } : {}) };
220
- if (ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, location, data, name === undefined ? undefined : (0, context_1.ownMacro)(ctx.run, name)).action === 'strip')
220
+ if (ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, location, data, name).action === 'strip')
221
221
  edits.objMacros.add(r.offset);
222
222
  }
223
223
  else if (r.type === biff_1.R.hlink) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/office-defuse",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Inspect Office and OpenDocument files for VBA and Excel 4.0 macros, LibreOffice Basic and scripts, DDE, external templates and links, embedded OLE objects and ActiveX controls, and score the risk. Strict ZIP and XML readers, bounded memory, no runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "patrickdk77",