@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/dist/objects.js CHANGED
@@ -11,7 +11,9 @@ const cfb_writer_1 = require("./cfb-writer");
11
11
  const contained_1 = require("./contained");
12
12
  const context_1 = require("./context");
13
13
  const errors_1 = require("./errors");
14
+ const forms_1 = require("./forms");
14
15
  const io_1 = require("./io");
16
+ const macros_1 = require("./macros");
15
17
  const oleobj_1 = require("./oleobj");
16
18
  const rewrite_cfb_1 = require("./rewrite-cfb");
17
19
  const types_1 = require("./types");
@@ -44,35 +46,78 @@ const at = (prefix, e) => [prefix, (0, cfb_1.entryPath)(e)].filter(Boolean).join
44
46
  */
45
47
  async function compoundRules(ctx, cfb, prefix, rootIsObject, plan, rootDecision) {
46
48
  const projects = new Set();
49
+ /** Storages of embedded objects, whose contents the plugins that take the object decide. */
50
+ const objects = new Set();
51
+ const inObject = (e) => {
52
+ for (let p = e.parent; p; p = p.parent)
53
+ if (objects.has(p))
54
+ return true;
55
+ return false;
56
+ };
57
+ /** User form designers of a VBA project that stays, which go with the project rather than as objects. */
58
+ const designers = new Set();
59
+ const inDesigner = (e) => {
60
+ for (let p = e; p; p = p.parent)
61
+ if (designers.has(p))
62
+ return true;
63
+ return false;
64
+ };
47
65
  const drop = (r, e) => {
48
66
  if (r.action === 'strip' && e.kind !== 'root')
49
67
  plan?.drop.add(e);
50
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.
51
78
  for (const e of cfb.entries) {
52
- if (e.kind === 'stream')
79
+ if (e.kind === 'stream' || inDesigner(e) || inAnswered(e))
53
80
  continue;
54
81
  const child = (name) => e.children.find(c => upper(c) === name);
82
+ const names = new Set(e.children.map(upper));
83
+ const parent = e.parent ? upper(e.parent) : '';
84
+ const isObject = e.kind === 'root'
85
+ ? rootIsObject
86
+ : [...names].some(n => OBJECT_STREAMS.has(n) || CONTROL_STREAMS.has(n)) ||
87
+ (parent === 'OBJECTPOOL' && e.parent?.parent?.kind === 'root') ||
88
+ (e.parent?.kind === 'root' && /^MBD[0-9A-F]{8}$/i.test(e.name));
89
+ if (isObject) {
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
+ }
55
99
  // A VBA project: a VBA storage with a dir stream, which names the modules (MS-OVBA 2.2). The storage that holds
56
- // the project goes, or the VBA storage when that is the root.
100
+ // the project goes, or the VBA storage when that is the root. With script plugins, the project stays when they
101
+ // pass every module and the rewrite can take out its compiled code: in the file the rewrite writes, and outside any
102
+ // embedded object, whose plugins decide what it holds.
57
103
  const vba = child('VBA');
58
104
  if (vba?.kind === 'storage' && vba.children.some(c => c.kind === 'stream' && upper(c) === 'DIR')) {
59
105
  projects.add(e);
60
- drop(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, at(prefix, vba), { storage: at(prefix, vba) }), e.kind === 'root' ? vba : e);
106
+ const decision = plan && !objects.has(e) && !inObject(e) ? await (0, macros_1.decideVba)(ctx, cfb, vba, at(prefix, vba), plan) : undefined;
107
+ const data = { storage: at(prefix, vba), ...(decision?.reason ? { reason: decision.reason } : {}) };
108
+ if (decision?.keep)
109
+ for (const d of decision.designers ?? [])
110
+ designers.add(d);
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);
61
116
  }
62
117
  if (e.kind === 'storage' && (upper(e) === '_VBA_PROJECT_CUR' || upper(e) === 'MACROS') && e.children.length && !projects.has(e)) {
63
118
  // Excel's and Word's project storages, without the dir stream a whole project has.
64
119
  drop(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, at(prefix, e), { storage: at(prefix, e) }), e);
65
120
  }
66
- const names = new Set(e.children.map(upper));
67
- const parent = e.parent ? upper(e.parent) : '';
68
- const isObject = e.kind === 'root'
69
- ? rootIsObject
70
- : [...names].some(n => OBJECT_STREAMS.has(n) || CONTROL_STREAMS.has(n)) ||
71
- (parent === 'OBJECTPOOL' && e.parent?.parent?.kind === 'root') ||
72
- (e.parent?.kind === 'root' && /^MBD[0-9A-F]{8}$/i.test(e.name));
73
- if (isObject)
74
- for (const r of await objectRules(ctx, cfb, e, at(prefix, e), plan, e.kind === 'root' ? rootDecision : undefined))
75
- drop(r, e);
76
121
  }
77
122
  }
78
123
  /** Reads an OLE object's storage: its class, its CompObj and OLE streams, and Packager's native data. */
@@ -159,6 +204,7 @@ async function objectFile(ctx, cfb, e, d, location, plan) {
159
204
  * One OLE object's storage: a control, a linked object, or an embedded one, with what it holds. Returns its findings,
160
205
  * and a strip when a plugin removed the object. In the file the rewrite writes, `plan` is set and an embedded object
161
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.
162
208
  */
163
209
  async function objectRules(ctx, cfb, e, location, plan, given) {
164
210
  const d = await describeObject(cfb, e, location);
@@ -169,8 +215,19 @@ async function objectRules(ctx, cfb, e, location, plan, given) {
169
215
  data.progId = (0, context_1.clip)(d.progId);
170
216
  if (d.userType)
171
217
  data.className = (0, context_1.clip)(d.userType);
172
- if (d.control)
173
- return [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, d.controlName ? { ...data, name: (0, context_1.clip)(d.controlName) } : data)];
218
+ if (d.control) {
219
+ if (d.controlName)
220
+ data.name = (0, context_1.clip)(d.controlName);
221
+ // A Forms 2.0 control stays when its storage and properties are as Office writes them. Its picture for printing,
222
+ // a metafile beside them, goes on its own.
223
+ const problem = await (0, forms_1.controlStorageProblem)(cfb, e, forms_1.WORD_CONTROL_STREAMS);
224
+ if (problem)
225
+ return { found: [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, { ...data, reason: (0, context_1.clip)(problem) })], answered: false };
226
+ const print = e.children.find(c => c.kind === 'stream' && c.name === '\x03PRINT');
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')
228
+ plan?.drop.add(print);
229
+ return { found: [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, location, data)], answered: false };
230
+ }
174
231
  const found = [];
175
232
  if (d.pkg) {
176
233
  Object.assign(data, packageData(d.pkg));
@@ -181,13 +238,15 @@ async function objectRules(ctx, cfb, e, location, plan, given) {
181
238
  found.push(linkedObject(ctx, d, location, { storage: location, ...(d.clsid ? { clsid: d.clsid } : {}) }));
182
239
  else {
183
240
  const decision = given ?? (plan ? await objectFile(ctx, cfb, e, d, location, plan) : undefined);
184
- 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));
185
244
  if (decision && !decision.keep)
186
245
  found.push({ action: 'strip' });
187
246
  }
188
247
  if (d.equation)
189
248
  found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Equation, location, data));
190
- return found;
249
+ return { found, answered: false };
191
250
  }
192
251
  /**
193
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/ole.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import type { CompoundFile } from './cfb';
2
- import type { RunContext } from './context';
1
+ import { type CompoundFile } from './cfb';
2
+ import { type RunContext } from './context';
3
3
  import type { Format } from './formats';
4
4
  import { ZipArchive } from './zip';
5
5
  /** The families a compound file can hold, by the stream or storage that marks each. */
6
- export type Family = 'word' | 'excel' | 'powerpoint' | 'visio' | 'outlook' | 'publisher' | 'project' | 'encrypted';
6
+ export type Family = 'word' | 'excel' | 'powerpoint' | 'powerpoint-addin' | 'visio' | 'outlook' | 'publisher' | 'project' | 'encrypted';
7
7
  /** What the root of a compound file holds, read by the names of its children, ignoring case as MS-CFB does. */
8
8
  export declare function families(cfb: CompoundFile): Family[];
9
9
  /**
package/dist/ole.js CHANGED
@@ -5,7 +5,10 @@ exports.mainFamily = mainFamily;
5
5
  exports.compoundFormat = compoundFormat;
6
6
  exports.openEncryptedPackage = openEncryptedPackage;
7
7
  exports.inspectCompound = inspectCompound;
8
+ const cfb_1 = require("./cfb");
9
+ const context_1 = require("./context");
8
10
  const errors_1 = require("./errors");
11
+ const forms_1 = require("./forms");
9
12
  const msg_1 = require("./msg");
10
13
  const objects_1 = require("./objects");
11
14
  const offcrypto_1 = require("./offcrypto");
@@ -20,6 +23,11 @@ const zip_1 = require("./zip");
20
23
  const f = (format, mimeType) => ({ format, mimeType });
21
24
  const upper = (e) => e.name.toUpperCase();
22
25
  const PUBLISHER_CLSIDS = new Set(['00021200-0000-0000-00C0-000000000046', '00021201-0000-0000-00C0-000000000046']);
26
+ /**
27
+ * The class id PowerPoint 97 to 2003 writes at the root of an add-in, a .ppa, whose root is its VBA project. An add-in
28
+ * whose project was removed keeps it, and is still an add-in.
29
+ */
30
+ const PPA_CLSID = '817246F0-720A-11CF-8718-00AA0060263B';
23
31
  /** What the root of a compound file holds, read by the names of its children, ignoring case as MS-CFB does. */
24
32
  function families(cfb) {
25
33
  const names = new Map(cfb.root.children.map(c => [upper(c), c]));
@@ -33,6 +41,8 @@ function families(cfb) {
33
41
  out.push('excel');
34
42
  if (stream('POWERPOINT DOCUMENT'))
35
43
  out.push('powerpoint');
44
+ if (cfb.root.clsid === PPA_CLSID)
45
+ out.push('powerpoint-addin');
36
46
  if (stream('VISIODOCUMENT'))
37
47
  out.push('visio');
38
48
  if (stream('__PROPERTIES_VERSION1.0'))
@@ -91,6 +101,8 @@ async function compoundFormat(cfb, family, deadline) {
91
101
  }
92
102
  case 'powerpoint':
93
103
  return f('ppt', 'application/vnd.ms-powerpoint');
104
+ case 'powerpoint-addin':
105
+ return f('ppa', 'application/vnd.ms-powerpoint');
94
106
  case 'visio':
95
107
  return f('vsd', 'application/vnd.visio');
96
108
  case 'outlook':
@@ -134,21 +146,29 @@ async function inspectCompound(ctx, cfb) {
134
146
  if (word)
135
147
  await (0, word_1.wordRules)(ctx, cfb, word, family === 'encrypted');
136
148
  if (family === 'excel') {
149
+ const found = [];
137
150
  for (const name of ['Workbook', 'Book']) {
138
151
  const s = stream(name);
139
152
  if (s)
140
- await (0, xls_1.excelRules)(ctx, cfb, s, offcrypto_1.unlockWorkbook);
153
+ await (0, xls_1.excelRules)(ctx, cfb, s, offcrypto_1.unlockWorkbook, plan, found);
141
154
  }
142
- // Excel keeps the data of a workbook's ActiveX controls in one Ctls stream.
155
+ // Excel keeps the data of a workbook's ActiveX controls in one Ctls stream. It stays when every control in it is a
156
+ // Forms 2.0 control as Office persists one, and the Obj records account for all of it.
143
157
  const controls = stream('Ctls');
144
- if (controls && controls.size > 0 && ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, controls.name, { stream: controls.name }).action === 'strip')
145
- plan.drop.add(controls);
158
+ if (controls && controls.size > 0) {
159
+ const problem = await ctlsProblem(cfb, controls, found);
160
+ const reported = problem
161
+ ? [ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, controls.name, { stream: controls.name, reason: (0, context_1.clip)(problem) })]
162
+ : found.map(c => ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, `${controls.name}, offset ${c.position}`, { stream: controls.name, sheet: (0, context_1.clip)(c.sheet), progId: (0, context_1.clip)(c.progId) }));
163
+ if (reported.some(r => r.action === 'strip'))
164
+ plan.drop.add(controls);
165
+ }
146
166
  }
147
167
  if (family === 'outlook')
148
168
  await (0, msg_1.messageRules)(ctx, cfb, cfb.root, 0, plan);
149
169
  const ppt = stream('PowerPoint Document');
150
170
  if (family === 'powerpoint' && ppt)
151
- await (0, ppt_1.powerPointRules)(ctx, cfb, ppt);
171
+ await (0, ppt_1.powerPointRules)(ctx, cfb, ppt, plan);
152
172
  const visio = stream('VisioDocument');
153
173
  if (family === 'visio' && visio)
154
174
  await (0, vsd_1.visioRules)(ctx, cfb, visio);
@@ -165,3 +185,33 @@ async function inspectCompound(ctx, cfb) {
165
185
  ctx.run.output = { kind: 'zip', zip, plan: zctx.plan, format: ctx.format };
166
186
  }
167
187
  }
188
+ /**
189
+ * Why the Ctls stream may hold more than Forms 2.0 controls as Office persists them, or undefined when it does not.
190
+ * Each control is its class id and then its properties, at the place its Obj record gives, and the places cover the
191
+ * stream with nothing between or after them.
192
+ */
193
+ async function ctlsProblem(cfb, ctls, controls) {
194
+ if (!controls.length)
195
+ return 'no Obj record names a control in it';
196
+ if (ctls.size > 16 * 1024 * 1024)
197
+ return 'it is larger than Forms 2.0 controls without pictures';
198
+ const bytes = await cfb.bytes(ctls);
199
+ let covered = 0;
200
+ for (const c of [...controls].sort((a, b) => a.position - b.position)) {
201
+ if (c.position !== covered)
202
+ return `bytes at offset ${covered} that no control's place covers`;
203
+ if (c.position + c.size > bytes.length || c.size < 16)
204
+ return `a control's place runs past the stream`;
205
+ const classId = forms_1.FORMS_PROGIDS.get(c.progId.trim().toLowerCase());
206
+ if (!classId)
207
+ return `a control of class ${c.progId || 'not named'}`;
208
+ const data = bytes.subarray(c.position, c.position + c.size);
209
+ if ((0, cfb_1.guid)(data, 0) !== classId)
210
+ return `a control whose data names another class than ${c.progId}`;
211
+ const problem = (0, forms_1.controlProblem)(classId, data.subarray(16));
212
+ if (problem)
213
+ return problem;
214
+ covered = c.position + c.size;
215
+ }
216
+ return covered === bytes.length ? undefined : `${bytes.length - covered} bytes after the last control`;
217
+ }
package/dist/ooxml.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ContainedDecision } from './contained';
1
2
  import { type ZipContext } from './context';
2
3
  import { type Format } from './formats';
3
4
  import { type XmlElement } from './xml';
@@ -52,6 +53,30 @@ export interface Part {
52
53
  /** The parts its own relationships reach. */
53
54
  targets: Set<Part>;
54
55
  ole: boolean;
56
+ /** For a VBA project part read before the XML parts, what became of it. */
57
+ vba?: ContainedDecision;
58
+ /** For an activeX part, the control its ax:ocx element describes. */
59
+ activeX?: ActiveXInfo;
60
+ /** For an activeX part, what the check of its control found. */
61
+ forms?: FormsVerdict;
62
+ /** A binary part that holds the data of a Forms 2.0 control that stays. */
63
+ formsData?: boolean;
64
+ }
65
+ /** What an activeX part's ax:ocx element says: the class, how the control persists itself, and a property bag. */
66
+ export interface ActiveXInfo {
67
+ classId: string;
68
+ persistence?: string;
69
+ props: Array<{
70
+ name: string;
71
+ value: string;
72
+ }>;
73
+ /** An element below ax:ocxPr, such as a font or picture, or one ax:ocx does not hold. */
74
+ nested: boolean;
75
+ }
76
+ /** An activeX part's control as checked: its class, and why it may not be a Forms 2.0 control as Office writes one. */
77
+ interface FormsVerdict {
78
+ classId?: string;
79
+ problem?: string;
55
80
  }
56
81
  /** What the rewrite needs of an OOXML package: its parts, which part each relationships part belongs to, and the roots. */
57
82
  export interface OoxmlGraph {
@@ -112,3 +137,4 @@ export declare function nameOoxml(ctx: ZipContext, contentTypes: ZipEntry): Prom
112
137
  * OPC package unpacked into a ZIP archive, and the package's own format is returned.
113
138
  */
114
139
  export declare function inspectOoxml(ctx: ZipContext, contentTypes: ZipEntry, outer?: Format): Promise<Format>;
140
+ export {};
package/dist/ooxml.js CHANGED
@@ -10,14 +10,19 @@ exports.readMain = readMain;
10
10
  exports.nameOoxml = nameOoxml;
11
11
  exports.inspectOoxml = inspectOoxml;
12
12
  const biff_1 = require("./biff");
13
+ const cfb_1 = require("./cfb");
13
14
  const contained_1 = require("./contained");
14
15
  const context_1 = require("./context");
15
16
  const errors_1 = require("./errors");
16
17
  const fields_1 = require("./fields");
17
18
  const formats_1 = require("./formats");
19
+ const forms_1 = require("./forms");
18
20
  const formulas_1 = require("./formulas");
21
+ const io_1 = require("./io");
19
22
  const links_1 = require("./links");
23
+ const objects_1 = require("./objects");
20
24
  const plan_1 = require("./plan");
25
+ const rewrite_cfb_1 = require("./rewrite-cfb");
21
26
  const types_1 = require("./types");
22
27
  const xlsb_1 = require("./xlsb");
23
28
  const xml_1 = require("./xml");
@@ -98,6 +103,7 @@ exports.R_NS = new Set(['http://schemas.openxmlformats.org/officedocument/2006/r
98
103
  const CUSTOM_UI_NS = new Set(['http://schemas.microsoft.com/office/2006/01/customui', 'http://schemas.microsoft.com/office/2009/07/customui']);
99
104
  const VML_EXCEL_NS = 'urn:schemas-microsoft-com:office:excel';
100
105
  const VISIO_NS = 'http://schemas.microsoft.com/office/visio/2012/main';
106
+ const ACTIVEX_NS = 'http://schemas.microsoft.com/office/2006/activex';
101
107
  const EXTENDED_NS = new Set(['http://schemas.openxmlformats.org/officedocument/2006/extended-properties', 'http://purl.oclc.org/ooxml/officedocument/extendedproperties']);
102
108
  /**
103
109
  * The attributes of a SpreadsheetML data connection that name where it loads from (ECMA-376 18.13): an ODC file or an
@@ -310,6 +316,8 @@ class PartRules {
310
316
  hyperlinkBase;
311
317
  /** The parts this XPS part names in its markup, by key, which count as reached from it. */
312
318
  refs = new Set();
319
+ /** The control an activeX part describes in its ax:ocx element. */
320
+ activeX;
313
321
  /**
314
322
  * The labels of the hyperlinks the part names by relationship Id: their text, tooltips and display text. The
315
323
  * relationships part comes after the part, so the targets are compared with these as it is read.
@@ -358,14 +366,27 @@ class PartRules {
358
366
  }
359
367
  start(el) {
360
368
  const ns = this.lowerNs(el);
369
+ // Below ax:ocx only ax:ocxPr may stand, and nothing below it: anything else marks the bag as more than plain values.
370
+ if (this.activeX && (el.depth > 2 || (el.depth === 2 && !(ns === ACTIVEX_NS && el.local === 'ocxPr'))))
371
+ this.activeX.nested = true;
372
+ if (ns === ACTIVEX_NS) {
373
+ const value = (local) => el.attributes.find(a => a.ns.toLowerCase() === ACTIVEX_NS && a.local === local)?.value;
374
+ if (el.depth === 1 && el.local === 'ocx')
375
+ this.activeX = { classId: value('classid') ?? '', persistence: value('persistence'), props: [], nested: false };
376
+ else if (this.activeX && el.depth === 2 && el.local === 'ocxPr')
377
+ this.activeX.props.push({ name: value('name') ?? '', value: value('value') ?? '' });
378
+ else if (this.activeX)
379
+ this.activeX.nested = true;
380
+ return;
381
+ }
361
382
  if (CUSTOM_UI_NS.has(ns)) {
362
383
  if (el.depth === 1)
363
384
  this.ctx.report(types_1.OfficeCategory.Ui, types_1.OfficeDetail.Ribbon, this.part);
364
385
  for (const a of el.attributes) {
365
386
  if (a.ns !== '' || !a.value.trim() || !/^(on[A-Z]|get[A-Z]|loadImage$)/.test(a.local))
366
387
  continue;
367
- // The callback goes and the control stays, doing nothing.
368
- if (this.ctx.report(types_1.OfficeCategory.Ui, types_1.OfficeDetail.Callback, this.part, { callback: `${a.local}=${(0, context_1.clip)(a.value)}` }).action === 'strip') {
388
+ // The callback goes and the control stays, doing nothing, unless it calls a macro of a project that stays.
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') {
369
390
  this.ctx.plan.element(this.part, el.index, { strip: new Set([(0, plan_1.attributeKey)('', a.local)]) });
370
391
  }
371
392
  }
@@ -398,7 +419,7 @@ class PartRules {
398
419
  else if (XDR_NS.has(ns)) {
399
420
  const macro = (0, xml_1.attr)(el, '', 'macro');
400
421
  // The shape stays, and clicking it runs nothing.
401
- if (macro?.trim() && this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(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') {
402
423
  this.ctx.plan.element(this.part, el.index, { strip: new Set([(0, plan_1.attributeKey)('', 'macro')]) });
403
424
  }
404
425
  }
@@ -455,8 +476,9 @@ class PartRules {
455
476
  this.formula = undefined;
456
477
  }
457
478
  if (ns === VML_EXCEL_NS && el.local === 'FmlaMacro' && this.macroText) {
479
+ const macro = this.macroText.shown.text.trim();
458
480
  if (this.macroText.shown.content)
459
- this.remove(this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, { macro: (0, context_1.clip)(this.macroText.shown.text.trim()) }), 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);
460
482
  this.macroText = undefined;
461
483
  }
462
484
  if (this.hyperlinkBase && EXTENDED_NS.has(ns) && el.local === 'HyperlinkBase') {
@@ -495,8 +517,10 @@ class PartRules {
495
517
  const data = { trigger: el.local, action: (0, context_1.clip)(written), ...(id === undefined ? {} : { relationship: (0, context_1.clip)(id) }) };
496
518
  if (action.startsWith('ppaction://program'))
497
519
  this.remove(this.ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.RunProgram, this.part, data), el);
498
- else if (action.startsWith('ppaction://macro'))
499
- this.remove(this.ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, data), el);
520
+ else if (action.startsWith('ppaction://macro')) {
521
+ const name = /[?&]name=([^&]*)/i.exec(written)?.[1] ?? '';
522
+ this.remove(this.ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, this.part, data, safeDecode(name)), el);
523
+ }
500
524
  else if (action.startsWith('ppaction://ole'))
501
525
  this.remove(this.ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.OleVerb, this.part, data), el);
502
526
  }
@@ -641,6 +665,12 @@ async function inspectOoxml(ctx, contentTypes, outer) {
641
665
  };
642
666
  const { format, strict, xps } = await readMain(ctx, typed, (rels, r) => relate(rels, undefined, r));
643
667
  ctx.format = outer ?? format;
668
+ // A VBA project goes to the script plugins before the XML parts are read, whose buttons and callbacks keep running
669
+ // its macros only when it stays.
670
+ if (ctx.run.options.scriptPlugins !== undefined)
671
+ for (const p of parts.values())
672
+ if (exports.TYPE.vba.has(p.contentType ?? ''))
673
+ p.vba = await vbaPart(ctx, p.entry, p.entry.name);
644
674
  // OPC has no folders. A directory entry is still inflated once, so its bytes are checked like any other.
645
675
  for (const entry of ctx.zip.entries)
646
676
  if (entry.directory)
@@ -661,6 +691,8 @@ async function inspectOoxml(ctx, contentTypes, outer) {
661
691
  await ctx.parse(p.entry, rules, ct === exports.TYPE.vml);
662
692
  if (rules.labels.size)
663
693
  labelsOf.set(key, rules.labels);
694
+ if (rules.activeX)
695
+ p.activeX = rules.activeX;
664
696
  for (const k of rules.refs) {
665
697
  const t = parts.get(k);
666
698
  if (t && t !== p)
@@ -686,6 +718,10 @@ async function inspectOoxml(ctx, contentTypes, outer) {
686
718
  for (const [source, rels] of relsOf)
687
719
  if (source !== '' && !parts.has(source))
688
720
  await readRelationships(ctx, rels, r => relate(rels, undefined, r));
721
+ // Controls are checked before any part's own rules, since a Forms 2.0 control that stays keeps its binary part.
722
+ for (const p of parts.values())
723
+ if (exports.TYPE.control.has(p.contentType ?? '') || (p.reachedAs & AS.control) !== 0)
724
+ p.forms = await formsControl(ctx, p);
689
725
  for (const p of parts.values())
690
726
  await partRules(ctx, p);
691
727
  unreferenced(ctx, parts, relsOf, roots);
@@ -711,28 +747,140 @@ async function partRules(ctx, p) {
711
747
  // Besides OLE parts and packages, every part that is a whole file in a format of its own, such as an image, goes
712
748
  // to the plugins. Such a part has no rule of its own, so one no plugin takes stays.
713
749
  const wholeFile = !(0, exports.isXmlType)(p.contentType) && !formatBinary(ct);
750
+ // The data of a Forms 2.0 control that stays was read with the control, and stays with it.
751
+ if (p.formsData)
752
+ return;
714
753
  const contained = vba || ole || pkg || controlBinary || p.ole || wholeFile;
715
- const decision = contained ? await (0, contained_1.decideContained)(ctx, { name: loc, declaredType: ct || undefined, location: loc, chunks: () => ctx.zip.read(p.entry) }) : undefined;
716
- const act = (0, contained_1.presence)(decision);
754
+ // With script plugins, a VBA project part is read here, so its modules go to them, rather than to the file plugins.
755
+ const vbaDecision = p.vba ?? (vba && ctx.run.options.scriptPlugins !== undefined ? await vbaPart(ctx, p.entry, loc) : undefined);
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);
717
757
  const found = [];
718
- if (vba)
719
- found.push(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, data, act));
758
+ // A plugin's answer is final, so a file it answered for gets none of office-defuse's own findings.
759
+ if (vba && !vbaDecision)
760
+ found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, data));
720
761
  // A macro sheet becomes an empty worksheet, so the indexes of the sheets after it stay right.
721
762
  if ((exports.TYPE.macrosheet.has(ct) || as(AS.macrosheet)) && ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, loc, data).action === 'strip')
722
763
  ctx.plan.sheets.add(key);
723
764
  if (ole)
724
- 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));
725
766
  if (pkg)
726
- found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Package, loc, data, act));
727
- if (control || (controlBinary && !p.claimed))
728
- found.push(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, data, control ? undefined : act));
767
+ found.push((0, contained_1.ownFinding)(ctx, decision, types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Package, loc, data));
768
+ if (control && p.forms && !p.forms.problem)
769
+ found.push(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, loc, { ...data, clsid: p.forms.classId ?? '' }));
770
+ else if (control || (controlBinary && !p.claimed)) {
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(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
+ }
729
774
  if (p.ole && !vba && !ole && !control && !controlBinary && !pkg)
730
- 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));
731
776
  if (decision ? !decision.keep : found.some(f => f.action === 'strip'))
732
777
  ctx.plan.drop.add(key);
733
778
  else if (decision?.replacement)
734
779
  ctx.plan.replace.set(key, decision.replacement);
735
780
  }
781
+ /** Bytes a Forms 2.0 control's binary part holds at most, pictures left out. */
782
+ const MAX_CONTROL_DATA = 65536;
783
+ /**
784
+ * Checks the control an activeX part describes as a Forms 2.0 control that Office persisted: by its class, and by
785
+ * its properties, read from the property bag in the part or from the binary part its relationship names. A control
786
+ * persisted as a stream starts with its class id; one persisted as a storage holds its CompObj and contents. A
787
+ * control that passes marks its binary part, which then stays with it.
788
+ */
789
+ async function formsControl(ctx, p) {
790
+ const ax = p.activeX;
791
+ if (!ax)
792
+ return { problem: 'its part has no ax:ocx element' };
793
+ const classId = ax.classId.replace(/[{}]/g, '').trim().toUpperCase();
794
+ if (!forms_1.FORMS_CONTROLS.has(classId))
795
+ return { classId, problem: 'its class is not a Forms 2.0 control office-defuse reads' };
796
+ const binaries = [...p.targets].filter(t => t.claimed);
797
+ const persistence = ax.persistence ?? 'persistStreamInit';
798
+ if (persistence === 'persistPropertyBag') {
799
+ if (binaries.length)
800
+ return { classId, problem: 'a property bag with a binary part' };
801
+ return { classId, problem: (0, forms_1.bagProblem)(classId, ax.props, ax.nested) };
802
+ }
803
+ if (ax.props.length || ax.nested)
804
+ return { classId, problem: `properties in its part beside ${persistence}` };
805
+ if (binaries.length !== 1)
806
+ return { classId, problem: 'it has no binary part, or more than one' };
807
+ const bin = binaries[0];
808
+ if (bin.entry.size > MAX_CONTROL_DATA)
809
+ return { classId, problem: 'its binary part is larger than a Forms 2.0 control without pictures' };
810
+ const chunks = [];
811
+ for await (const c of ctx.zip.read(bin.entry))
812
+ chunks.push(c);
813
+ const bytes = Buffer.concat(chunks);
814
+ let problem;
815
+ if (persistence === 'persistStreamInit' || persistence === 'persistStream') {
816
+ problem = bytes.length < 16 || (0, cfb_1.guid)(bytes, 0) !== classId ? 'its binary part names another class' : (0, forms_1.controlProblem)(classId, bytes.subarray(16));
817
+ }
818
+ else if (persistence === 'persistStorage') {
819
+ let cfb;
820
+ try {
821
+ cfb = await cfb_1.CompoundFile.open((0, io_1.bufferSource)(bytes), ctx.settings, ctx.run.deadline);
822
+ }
823
+ catch (e) {
824
+ if (!(e instanceof errors_1.StopError) || e.category !== types_1.OfficeCategory.Container)
825
+ throw e;
826
+ problem = `its binary part is not a compound file: ${e.message}`;
827
+ }
828
+ if (cfb)
829
+ problem = await (0, forms_1.controlStorageProblem)(cfb, cfb.root, forms_1.OOXML_CONTROL_STREAMS, classId);
830
+ }
831
+ else
832
+ problem = `its persistence is ${persistence}`;
833
+ if (!problem)
834
+ bin.formsData = true;
835
+ return { classId, problem };
836
+ }
837
+ /**
838
+ * A VBA project part, read as the compound file it is: the rules every compound file gets, which show the project to
839
+ * the script plugins and the storages of its forms to the file plugins. The part stays, as the rewrite writes it, when
840
+ * the project does.
841
+ */
842
+ async function vbaPart(ctx, entry, loc) {
843
+ const run = ctx.run;
844
+ const copy = new io_1.SpillSink(run.temp, run.threshold);
845
+ run.held.push(copy);
846
+ for await (const c of ctx.zip.read(entry))
847
+ await copy.write(c);
848
+ await copy.close();
849
+ const source = copy.source();
850
+ run.sources.push(source);
851
+ let cfb;
852
+ try {
853
+ cfb = await cfb_1.CompoundFile.open(source, ctx.settings, run.deadline);
854
+ }
855
+ catch (e) {
856
+ if (!(e instanceof errors_1.StopError) || e.category !== types_1.OfficeCategory.Container)
857
+ throw e;
858
+ ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, { part: loc, reason: (0, context_1.clip)(`its compound file does not read: ${e.message}`) });
859
+ return { keep: false };
860
+ }
861
+ (0, objects_1.nestedNotes)(ctx, cfb, loc);
862
+ const plan = new plan_1.CompoundPlan();
863
+ await (0, objects_1.compoundRules)(ctx, cfb, loc, false, plan);
864
+ const vba = cfb.child(cfb.root, 'VBA');
865
+ if (!vba)
866
+ ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, { part: loc, reason: 'it holds no VBA storage' });
867
+ if (!vba || plan.drop.has(vba))
868
+ return { keep: false };
869
+ const out = new io_1.SpillSink(run.temp, run.threshold);
870
+ run.held.push(out);
871
+ await (0, rewrite_cfb_1.writeCompound)(run, cfb, plan, out);
872
+ await out.close();
873
+ return { keep: true, replacement: out };
874
+ }
875
+ /** A percent-encoded value, or the value as written when it does not decode. */
876
+ function safeDecode(v) {
877
+ try {
878
+ return decodeURIComponent(v);
879
+ }
880
+ catch {
881
+ return v;
882
+ }
883
+ }
736
884
  /** Parts that no chain of internal relationships reaches from the package. */
737
885
  function unreferenced(ctx, parts, relsOf, roots) {
738
886
  const reached = new Set(roots);