@patrickdk77/office-defuse 0.2.1 → 0.2.4
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 +82 -10
- package/dist/biff.d.ts +11 -0
- package/dist/biff.js +15 -0
- package/dist/contained.d.ts +5 -0
- package/dist/contained.js +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.js +10 -0
- package/dist/engine.js +2 -0
- package/dist/fields.js +1 -1
- package/dist/findings.js +7 -1
- package/dist/forms.d.ts +51 -0
- package/dist/forms.js +316 -0
- package/dist/macros.d.ts +21 -0
- package/dist/macros.js +149 -0
- package/dist/objects.js +50 -12
- package/dist/ole.d.ts +3 -3
- package/dist/ole.js +55 -5
- package/dist/ooxml.d.ts +26 -0
- package/dist/ooxml.js +164 -10
- package/dist/ppt-writer.d.ts +27 -0
- package/dist/ppt-writer.js +105 -0
- package/dist/ppt.d.ts +5 -1
- package/dist/ppt.js +177 -46
- package/dist/rewrite-zip.js +2 -1
- package/dist/types.d.ts +44 -0
- package/dist/types.js +3 -0
- package/dist/vba.d.ts +78 -0
- package/dist/vba.js +378 -0
- package/dist/xls-writer.d.ts +34 -0
- package/dist/xls-writer.js +250 -0
- package/dist/xls.d.ts +9 -1
- package/dist/xls.js +126 -43
- package/dist/xml.d.ts +3 -0
- package/dist/xml.js +19 -1
- package/package.json +1 -1
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
|
|
145
|
-
|
|
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.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') {
|
|
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.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') {
|
|
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.
|
|
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);
|
|
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
|
-
|
|
520
|
+
else if (action.startsWith('ppaction://macro')) {
|
|
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);
|
|
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
|
}
|
|
@@ -597,6 +621,8 @@ async function readMain(ctx, { parts, relsOf }, each) {
|
|
|
597
621
|
async function nameOoxml(ctx, contentTypes) {
|
|
598
622
|
return (await readMain(ctx, await readParts(ctx, contentTypes))).format;
|
|
599
623
|
}
|
|
624
|
+
/** The content type of an add-in's presentation part, which PowerPoint writes empty. */
|
|
625
|
+
const PPAM_MAIN = 'application/vnd.ms-powerpoint.addin.macroenabled.main+xml';
|
|
600
626
|
/**
|
|
601
627
|
* Inspects an OOXML package. The package relationships come first, since they name the main part and so the format.
|
|
602
628
|
* Then each part is read in archive order, followed by its relationships, which are reported as they are read and
|
|
@@ -639,6 +665,12 @@ async function inspectOoxml(ctx, contentTypes, outer) {
|
|
|
639
665
|
};
|
|
640
666
|
const { format, strict, xps } = await readMain(ctx, typed, (rels, r) => relate(rels, undefined, r));
|
|
641
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);
|
|
642
674
|
// OPC has no folders. A directory entry is still inflated once, so its bytes are checked like any other.
|
|
643
675
|
for (const entry of ctx.zip.entries)
|
|
644
676
|
if (entry.directory)
|
|
@@ -650,11 +682,17 @@ async function inspectOoxml(ctx, contentTypes, outer) {
|
|
|
650
682
|
plan.drop.add(key);
|
|
651
683
|
p.ole = await ctx.drain(p.entry);
|
|
652
684
|
}
|
|
685
|
+
else if (format.format === 'ppam' && p.entry.size === 0 && ct.toLowerCase() === PPAM_MAIN) {
|
|
686
|
+
// PowerPoint writes an add-in's presentation part empty, since an add-in has no slides.
|
|
687
|
+
await ctx.drain(p.entry);
|
|
688
|
+
}
|
|
653
689
|
else if ((0, exports.isXmlType)(ct)) {
|
|
654
690
|
const rules = new PartRules(ctx, p.entry.name);
|
|
655
691
|
await ctx.parse(p.entry, rules, ct === exports.TYPE.vml);
|
|
656
692
|
if (rules.labels.size)
|
|
657
693
|
labelsOf.set(key, rules.labels);
|
|
694
|
+
if (rules.activeX)
|
|
695
|
+
p.activeX = rules.activeX;
|
|
658
696
|
for (const k of rules.refs) {
|
|
659
697
|
const t = parts.get(k);
|
|
660
698
|
if (t && t !== p)
|
|
@@ -680,6 +718,10 @@ async function inspectOoxml(ctx, contentTypes, outer) {
|
|
|
680
718
|
for (const [source, rels] of relsOf)
|
|
681
719
|
if (source !== '' && !parts.has(source))
|
|
682
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);
|
|
683
725
|
for (const p of parts.values())
|
|
684
726
|
await partRules(ctx, p);
|
|
685
727
|
unreferenced(ctx, parts, relsOf, roots);
|
|
@@ -705,11 +747,16 @@ async function partRules(ctx, p) {
|
|
|
705
747
|
// Besides OLE parts and packages, every part that is a whole file in a format of its own, such as an image, goes
|
|
706
748
|
// to the plugins. Such a part has no rule of its own, so one no plugin takes stays.
|
|
707
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;
|
|
708
753
|
const contained = vba || ole || pkg || controlBinary || p.ole || wholeFile;
|
|
709
|
-
|
|
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);
|
|
710
757
|
const act = (0, contained_1.presence)(decision);
|
|
711
758
|
const found = [];
|
|
712
|
-
if (vba)
|
|
759
|
+
if (vba && !vbaDecision)
|
|
713
760
|
found.push(ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, loc, data, act));
|
|
714
761
|
// A macro sheet becomes an empty worksheet, so the indexes of the sheets after it stay right.
|
|
715
762
|
if ((exports.TYPE.macrosheet.has(ct) || as(AS.macrosheet)) && ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, loc, data).action === 'strip')
|
|
@@ -718,8 +765,12 @@ async function partRules(ctx, p) {
|
|
|
718
765
|
found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, loc, data, act));
|
|
719
766
|
if (pkg)
|
|
720
767
|
found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.Package, loc, data, act));
|
|
721
|
-
if (control
|
|
722
|
-
found.push(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.
|
|
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(ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, loc, { ...data, ...why }, control ? undefined : act));
|
|
773
|
+
}
|
|
723
774
|
if (p.ole && !vba && !ole && !control && !controlBinary && !pkg)
|
|
724
775
|
found.push(ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleFile, loc, data, act));
|
|
725
776
|
if (decision ? !decision.keep : found.some(f => f.action === 'strip'))
|
|
@@ -727,6 +778,109 @@ async function partRules(ctx, p) {
|
|
|
727
778
|
else if (decision?.replacement)
|
|
728
779
|
ctx.plan.replace.set(key, decision.replacement);
|
|
729
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
|
+
}
|
|
730
884
|
/** Parts that no chain of internal relationships reaches from the package. */
|
|
731
885
|
function unreferenced(ctx, parts, relsOf, roots) {
|
|
732
886
|
const reached = new Set(roots);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SpillSink } from './io';
|
|
2
|
+
import type { ByteSource } from './types';
|
|
3
|
+
/** What the rewrite of a PowerPoint Document stream changes, each record by its offset in the stream as read. */
|
|
4
|
+
export interface PresentationEdits {
|
|
5
|
+
/** Records that go, with everything inside them. */
|
|
6
|
+
remove: Set<number>;
|
|
7
|
+
/** Records whose body is replaced, the header written for the new length. */
|
|
8
|
+
replace: Map<number, Uint8Array>;
|
|
9
|
+
/** Persist objects, by persist id, that the new persist directory leaves out. */
|
|
10
|
+
dropPersist: Set<number>;
|
|
11
|
+
/** The presentation is rewritten only to decrypt it. A rewrite of an encrypted presentation always decrypts it. */
|
|
12
|
+
decrypt: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare const noPresentationEdits: () => PresentationEdits;
|
|
15
|
+
export declare const hasPresentationEdits: (e: PresentationEdits) => boolean;
|
|
16
|
+
/** The current edit as its UserEditAtom (MS-PPT 2.3.3) gives it. */
|
|
17
|
+
export interface CurrentEdit {
|
|
18
|
+
/** The atom's body, kept but for the offsets the rewrite writes anew. */
|
|
19
|
+
body: Uint8Array;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Writes a PowerPoint Document stream again as a full save (MS-PPT 2.1.2): each persist object the current edit
|
|
23
|
+
* reaches, in persist id order, then one persist directory for them and one UserEditAtom with no edit before it.
|
|
24
|
+
* `source` is the stream as read, decrypted when it was encrypted, and `persist` maps each persist id to the offset of
|
|
25
|
+
* its object. Returns the offset of the new UserEditAtom, which the Current User stream points at.
|
|
26
|
+
*/
|
|
27
|
+
export declare function rewritePresentation(source: ByteSource, size: number, persist: ReadonlyMap<number, number>, current: CurrentEdit, edits: PresentationEdits, sink: SpillSink, deadline?: number): Promise<number>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hasPresentationEdits = exports.noPresentationEdits = void 0;
|
|
4
|
+
exports.rewritePresentation = rewritePresentation;
|
|
5
|
+
const errors_1 = require("./errors");
|
|
6
|
+
const noPresentationEdits = () => ({ remove: new Set(), replace: new Map(), dropPersist: new Set(), decrypt: false });
|
|
7
|
+
exports.noPresentationEdits = noPresentationEdits;
|
|
8
|
+
const hasPresentationEdits = (e) => e.decrypt || e.remove.size > 0 || e.replace.size > 0 || e.dropPersist.size > 0;
|
|
9
|
+
exports.hasPresentationEdits = hasPresentationEdits;
|
|
10
|
+
const USER_EDIT = 0x0ff5;
|
|
11
|
+
const PERSIST_DIRECTORY = 0x1772;
|
|
12
|
+
const u32 = (b, at) => (b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24)) >>> 0;
|
|
13
|
+
const le32 = (n) => Uint8Array.of(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
|
|
14
|
+
function head(verInst, type, length) {
|
|
15
|
+
return Uint8Array.of(verInst & 0xff, verInst >> 8, type & 0xff, type >> 8, ...le32(length));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The bytes of one record, from `offset` in `source`, with the edits inside it made. A container is written from its
|
|
19
|
+
* children, so its length follows what they become; a record without edits inside is copied as it is.
|
|
20
|
+
*/
|
|
21
|
+
async function recordBytes(source, offset, end, edits, touched, deadline) {
|
|
22
|
+
(0, errors_1.checkTime)(deadline);
|
|
23
|
+
const h = await source.read(offset, 8);
|
|
24
|
+
const verInst = h[0] | (h[1] << 8);
|
|
25
|
+
const type = h[2] | (h[3] << 8);
|
|
26
|
+
const length = Math.min(u32(h, 4), end - offset - 8);
|
|
27
|
+
if (edits.remove.has(offset))
|
|
28
|
+
return [];
|
|
29
|
+
const replaced = edits.replace.get(offset);
|
|
30
|
+
if (replaced)
|
|
31
|
+
return [head(verInst, type, replaced.length), replaced];
|
|
32
|
+
const bodyEnd = offset + 8 + length;
|
|
33
|
+
if ((verInst & 0x0f) !== 0x0f || !touched(offset + 8, bodyEnd))
|
|
34
|
+
return [head(verInst, type, length), await source.read(offset + 8, length)];
|
|
35
|
+
const parts = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
for (let pos = offset + 8; pos + 8 <= bodyEnd;) {
|
|
38
|
+
const kh = await source.read(pos, 8);
|
|
39
|
+
const kidEnd = Math.min(bodyEnd, pos + 8 + u32(kh, 4));
|
|
40
|
+
for (const p of await recordBytes(source, pos, kidEnd, edits, touched, deadline)) {
|
|
41
|
+
parts.push(p);
|
|
42
|
+
total += p.length;
|
|
43
|
+
}
|
|
44
|
+
pos = kidEnd;
|
|
45
|
+
}
|
|
46
|
+
return [head(verInst, type, total), ...parts];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Writes a PowerPoint Document stream again as a full save (MS-PPT 2.1.2): each persist object the current edit
|
|
50
|
+
* reaches, in persist id order, then one persist directory for them and one UserEditAtom with no edit before it.
|
|
51
|
+
* `source` is the stream as read, decrypted when it was encrypted, and `persist` maps each persist id to the offset of
|
|
52
|
+
* its object. Returns the offset of the new UserEditAtom, which the Current User stream points at.
|
|
53
|
+
*/
|
|
54
|
+
async function rewritePresentation(source, size, persist, current, edits, sink, deadline) {
|
|
55
|
+
const marks = [...edits.remove, ...edits.replace.keys()].sort((a, b) => a - b);
|
|
56
|
+
const touched = (from, to) => {
|
|
57
|
+
let lo = 0;
|
|
58
|
+
let hi = marks.length;
|
|
59
|
+
while (lo < hi) {
|
|
60
|
+
const mid = (lo + hi) >> 1;
|
|
61
|
+
if (marks[mid] < from)
|
|
62
|
+
lo = mid + 1;
|
|
63
|
+
else
|
|
64
|
+
hi = mid;
|
|
65
|
+
}
|
|
66
|
+
return lo < marks.length && marks[lo] < to;
|
|
67
|
+
};
|
|
68
|
+
const written = new Map();
|
|
69
|
+
let pos = 0;
|
|
70
|
+
for (const [id, offset] of [...persist].sort((a, b) => a[0] - b[0])) {
|
|
71
|
+
if (edits.dropPersist.has(id) || offset + 8 > size)
|
|
72
|
+
continue;
|
|
73
|
+
const parts = await recordBytes(source, offset, size, edits, touched, deadline);
|
|
74
|
+
if (!parts.length)
|
|
75
|
+
continue;
|
|
76
|
+
written.set(id, pos);
|
|
77
|
+
for (const p of parts) {
|
|
78
|
+
await sink.write(p);
|
|
79
|
+
pos += p.length;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// The directory: runs of consecutive ids, each a 20-bit first id and a 12-bit count, then their offsets.
|
|
83
|
+
const ids = [...written.keys()].sort((a, b) => a - b);
|
|
84
|
+
const dir = [];
|
|
85
|
+
for (let i = 0; i < ids.length;) {
|
|
86
|
+
let n = 1;
|
|
87
|
+
while (i + n < ids.length && ids[i + n] === ids[i] + n && n < 0xfff)
|
|
88
|
+
n++;
|
|
89
|
+
dir.push(...le32((ids[i] & 0xfffff) | (n << 20)));
|
|
90
|
+
for (let k = 0; k < n; k++)
|
|
91
|
+
dir.push(...le32(written.get(ids[i + k]) ?? 0));
|
|
92
|
+
i += n;
|
|
93
|
+
}
|
|
94
|
+
const dirAt = pos;
|
|
95
|
+
const dirRecord = Uint8Array.from([...head(0, PERSIST_DIRECTORY, dir.length), ...dir]);
|
|
96
|
+
await sink.write(dirRecord);
|
|
97
|
+
pos += dirRecord.length;
|
|
98
|
+
// The UserEditAtom: no edit before it, the new directory, and no encryption session once decrypted.
|
|
99
|
+
const body = Uint8Array.from(current.body.subarray(0, edits.decrypt ? 28 : current.body.length));
|
|
100
|
+
body.set(le32(0), 8);
|
|
101
|
+
body.set(le32(dirAt), 12);
|
|
102
|
+
const editAt = pos;
|
|
103
|
+
await sink.write(Uint8Array.from([...head(0, USER_EDIT, body.length), ...body]));
|
|
104
|
+
return editAt;
|
|
105
|
+
}
|
package/dist/ppt.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { type CfbEntry, CompoundFile } from './cfb';
|
|
2
2
|
import { type RunContext } from './context';
|
|
3
|
+
import { CompoundPlan } from './plan';
|
|
3
4
|
/**
|
|
4
5
|
* Inspects a PowerPoint 97 to 2003 presentation. PowerPoint finds its objects through the persist directory that the
|
|
5
6
|
* current edit names. This reads every record at the top level of the stream as well as every object the persist
|
|
6
7
|
* directories name, so an object that one route hides is still read.
|
|
8
|
+
*
|
|
9
|
+
* With `plan`, what the strip findings name is removed by a full save of the stream: the persist objects the current
|
|
10
|
+
* edit reaches, written again with the records that go left out, a new persist directory and a new UserEditAtom.
|
|
7
11
|
*/
|
|
8
|
-
export declare function powerPointRules(ctx: RunContext, cfb: CompoundFile, stream: CfbEntry): Promise<void>;
|
|
12
|
+
export declare function powerPointRules(ctx: RunContext, cfb: CompoundFile, stream: CfbEntry, plan?: CompoundPlan): Promise<void>;
|