@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.
@@ -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>;
package/dist/ppt.js CHANGED
@@ -39,11 +39,15 @@ const cfb_1 = require("./cfb");
39
39
  const contained_1 = require("./contained");
40
40
  const context_1 = require("./context");
41
41
  const errors_1 = require("./errors");
42
+ const forms_1 = require("./forms");
42
43
  const io_1 = require("./io");
43
44
  const links_1 = require("./links");
44
45
  const objects_1 = require("./objects");
45
46
  const offcrypto_1 = require("./offcrypto");
46
47
  const oleobj_1 = require("./oleobj");
48
+ const plan_1 = require("./plan");
49
+ const ppt_writer_1 = require("./ppt-writer");
50
+ const rewrite_cfb_1 = require("./rewrite-cfb");
47
51
  const types_1 = require("./types");
48
52
  /** Record types of the PowerPoint binary format (MS-PPT 2.13.24). */
49
53
  const T = {
@@ -71,29 +75,44 @@ const header = (b) => ({ version: b[0] & 0x0f, instance: (b[0] | (b[1] << 8)) >>
71
75
  * Inspects a PowerPoint 97 to 2003 presentation. PowerPoint finds its objects through the persist directory that the
72
76
  * current edit names. This reads every record at the top level of the stream as well as every object the persist
73
77
  * directories name, so an object that one route hides is still read.
78
+ *
79
+ * With `plan`, what the strip findings name is removed by a full save of the stream: the persist objects the current
80
+ * edit reaches, written again with the records that go left out, a new persist directory and a new UserEditAtom.
74
81
  */
75
- async function powerPointRules(ctx, cfb, stream) {
82
+ async function powerPointRules(ctx, cfb, stream, plan) {
76
83
  const location = stream.name;
77
84
  let source = cfb.source(stream);
78
85
  let reader = new io_1.Reader(source, stream.size, 16384, 8);
79
86
  const persist = new Map();
87
+ const edits = (0, ppt_writer_1.noPresentationEdits)();
80
88
  const current = cfb.child(cfb.root, 'Current User');
89
+ let editAt = 0;
90
+ let session;
81
91
  if (current?.kind === 'stream') {
82
92
  const cu = await cfb.source(current).read(0, 24);
83
93
  if (cu.length >= 20 && header(cu).type === T.currentUserAtom) {
84
- await readPersist(reader, stream.size, u32(cu, 16), persist, ctx.settings.deadline);
94
+ editAt = u32(cu, 16);
95
+ await readPersist(reader, stream.size, editAt, persist, ctx.settings.deadline);
85
96
  if (u32(cu, 12) === ENCRYPTED_TOKEN) {
86
- source = await decryptPresentation(ctx, reader, stream, u32(cu, 16), persist);
97
+ const before = ctx.findings.length;
98
+ const opened = await decryptPresentation(ctx, reader, stream, editAt, persist);
99
+ source = opened.source;
100
+ session = opened.session;
87
101
  reader = new io_1.Reader(source, stream.size, 16384, 8);
102
+ // The rewrite writes the presentation decrypted, so one VelvetSweatshop opens is rewritten when that finding strips.
103
+ if (ctx.findings.slice(before).some(f => f.category === types_1.OfficeCategory.Encrypted && f.detail === types_1.OfficeDetail.DefaultPassword && f.action === 'strip'))
104
+ edits.decrypt = true;
88
105
  }
89
106
  }
90
107
  }
91
108
  const storages = new Map();
92
- /** ExOleObjAtom types by the persist id of the storage they name: 0 embedded, 1 linked, 2 a control. */
109
+ /** ExOleObjAtoms by the persist id of the storage they name: the object's type, 0 embedded, 1 linked, 2 a control, and the container that holds the atom. */
93
110
  const roles = new Map();
94
- const vba = new Set();
111
+ /** VBAInfoAtoms by the persist id of the project's storage, with the VBAInfoContainer around each. */
112
+ const vba = new Map();
113
+ const containers = [];
95
114
  const visited = new Set();
96
- const walk = async (start, end, depth, siblings) => {
115
+ const walk = async (start, end, depth, siblings, parent) => {
97
116
  for (let pos = start; pos + 8 <= end;) {
98
117
  (0, errors_1.checkTime)(ctx.settings.deadline);
99
118
  const h = header(await reader.read(pos, 8));
@@ -106,32 +125,87 @@ async function powerPointRules(ctx, cfb, stream) {
106
125
  storages.set(pos, { offset: pos, header: h });
107
126
  else if (h.type === T.exOleObjAtom && h.length >= 20) {
108
127
  const b = await reader.read(pos + 8, 20);
109
- roles.set(u32(b, 16), u32(b, 4));
128
+ roles.set(u32(b, 16), { type: u32(b, 4), container: parent });
129
+ }
130
+ else if (h.type === T.vbaInfoAtom && h.length >= 4) {
131
+ const id = u32(await reader.read(pos + 8, 4), 0);
132
+ vba.set(id, [...(vba.get(id) ?? []), parent]);
110
133
  }
111
- else if (h.type === T.vbaInfoAtom && h.length >= 4)
112
- vba.add(u32(await reader.read(pos + 8, 4), 0));
113
134
  if (h.version === 0x0f && depth < MAX_DEPTH) {
114
135
  const kids = [];
115
- await walk(pos + 8, bodyEnd, depth + 1, kids);
116
- await containerRules(ctx, reader, h, pos, kids, location);
136
+ await walk(pos + 8, bodyEnd, depth + 1, kids, pos);
137
+ containers.push({ h, offset: pos, kids });
117
138
  }
118
139
  pos = bodyEnd;
119
140
  }
120
141
  };
121
142
  // Every record at the top level, then each persist object that does not start on a record already read.
122
- await walk(0, stream.size, 0, []);
143
+ await walk(0, stream.size, 0, [], -1);
123
144
  for (const offset of persist.values()) {
124
145
  if (visited.has(offset) || offset + 8 > stream.size)
125
146
  continue;
126
147
  const h = header(await reader.read(offset, 8));
127
- await walk(offset, Math.min(stream.size, offset + 8 + h.length), 0, []);
148
+ await walk(offset, Math.min(stream.size, offset + 8 + h.length), 0, [], -1);
128
149
  }
150
+ // The storages come first: a VBA project that stays keeps the actions that run its macros.
129
151
  const byOffset = new Map([...persist].map(([id, offset]) => [offset, id]));
130
152
  for (const s of storages.values()) {
131
153
  const id = byOffset.get(s.offset);
132
- const role = id !== undefined && vba.has(id) ? 'vba' : id !== undefined && roles.get(id) === 2 ? 'control' : 'object';
133
- await storageRules(ctx, source, s, role, `${location}, offset ${s.offset}`);
154
+ const role = id !== undefined && vba.has(id) ? 'vba' : id !== undefined && roles.get(id)?.type === 2 ? 'control' : 'object';
155
+ const verdict = await storageRules(ctx, source, s, role, `${location}, offset ${s.offset}`, plan !== undefined);
156
+ if (id === undefined || verdict === 'keep')
157
+ continue;
158
+ if (verdict === 'remove') {
159
+ // The storage goes, and so does what names it: the VBAInfoContainer, or the object's container in ExObjList.
160
+ edits.dropPersist.add(id);
161
+ if (role === 'vba')
162
+ for (const c of vba.get(id) ?? [])
163
+ edits.remove.add(c);
164
+ else {
165
+ const holder = roles.get(id)?.container;
166
+ if (holder !== undefined && holder >= 0)
167
+ edits.remove.add(holder);
168
+ }
169
+ }
170
+ else
171
+ edits.replace.set(s.offset, verdict.body);
172
+ }
173
+ const links = new Map();
174
+ const actions = [];
175
+ for (const c of containers)
176
+ await containerRules(ctx, reader, c.h, c.offset, c.kids, location, edits, links, actions);
177
+ // An action that opens a hyperlink that went does nothing.
178
+ for (const a of actions) {
179
+ if (a.action !== 4 || !links.has(a.hyperlink) || edits.replace.has(a.offset))
180
+ continue;
181
+ const body = Uint8Array.from(await reader.read(a.offset + 8, 16));
182
+ body[8] = 0;
183
+ edits.replace.set(a.offset, body);
134
184
  }
185
+ if (!plan || !current || !(0, ppt_writer_1.hasPresentationEdits)(edits))
186
+ return;
187
+ // An encrypted presentation's pictures and summary are encrypted too, in streams this rewrite does not decrypt. It
188
+ // is left as it was, and the check of the output says what it still holds.
189
+ if (session !== undefined && ['Pictures', 'EncryptedSummary'].some(n => (cfb.child(cfb.root, n)?.size ?? 0) > 0))
190
+ return;
191
+ if (session !== undefined) {
192
+ edits.decrypt = true;
193
+ edits.dropPersist.add(session);
194
+ }
195
+ const userEdit = header(await reader.read(editAt, 8));
196
+ const body = await reader.read(editAt + 8, Math.min(userEdit.length, 64));
197
+ if (userEdit.type !== T.userEditAtom || body.length < 28)
198
+ return;
199
+ const sink = new io_1.SpillSink(ctx.run.temp, ctx.run.threshold);
200
+ ctx.run.held.push(sink);
201
+ const newEdit = await (0, ppt_writer_1.rewritePresentation)(source, stream.size, persist, { body }, edits, sink, ctx.settings.deadline);
202
+ await sink.close();
203
+ plan.replace.set(stream, sink);
204
+ const user = Uint8Array.from(await cfb.bytes(current));
205
+ user.set([newEdit & 0xff, (newEdit >>> 8) & 0xff, (newEdit >>> 16) & 0xff, (newEdit >>> 24) & 0xff], 16);
206
+ if (edits.decrypt)
207
+ user.set([0x5f, 0xc0, 0x91, 0xe3], 12);
208
+ plan.replace.set(current, await (0, contained_1.spillOf)(ctx.run, user));
135
209
  }
136
210
  /**
137
211
  * The PowerPoint Document stream of an encrypted presentation, decrypted (MS-PPT 2.3.7). The current edit names the
@@ -142,7 +216,8 @@ async function powerPointRules(ctx, cfb, stream) {
142
216
  async function decryptPresentation(ctx, reader, stream, edit, persist) {
143
217
  const required = (reason) => new errors_1.StopError(types_1.OfficeCategory.Encrypted, types_1.OfficeDetail.PasswordRequired, `The presentation is encrypted: ${reason}`, { stream: stream.name, reason });
144
218
  const u = await reader.read(edit, 40);
145
- const sessionAt = u.length >= 40 && header(u).type === T.userEditAtom ? persist.get(u32(u, 36)) : undefined;
219
+ const sessionId = u.length >= 40 && header(u).type === T.userEditAtom ? u32(u, 36) : -1;
220
+ const sessionAt = persist.get(sessionId);
146
221
  if (sessionAt === undefined || sessionAt + 8 > stream.size)
147
222
  throw required('the current edit names no CryptSession10Container');
148
223
  const h = header(await reader.read(sessionAt, 8));
@@ -162,7 +237,7 @@ async function decryptPresentation(ctx, reader, stream, edit, persist) {
162
237
  copy.set(head, offset);
163
238
  copy.set(rc4.crypt(copy.subarray(offset + 8, offset + 8 + length)), offset + 8);
164
239
  }
165
- return (0, io_1.bufferSource)(copy);
240
+ return { source: (0, io_1.bufferSource)(copy), session: sessionId };
166
241
  }
167
242
  /** Reads the persist directories of the current edit and every edit before it. Newer entries win. */
168
243
  async function readPersist(reader, size, start, persist, deadline) {
@@ -190,10 +265,15 @@ async function readPersist(reader, size, start, persist, deadline) {
190
265
  edit = u32(u, 16);
191
266
  }
192
267
  }
193
- /** One ExOleObjStg: a VBA project, a control or an OLE object, stored as a compound file that may be compressed. */
194
- async function storageRules(ctx, source, s, role, location) {
268
+ /**
269
+ * One ExOleObjStg: a VBA project, a control or an OLE object, stored as a compound file that may be compressed. With
270
+ * `rewrite`, a VBA project goes to the script plugins and stays, without its compiled code, when they pass it; a
271
+ * Forms 2.0 control stays; an object stays as the file plugins left it. Anything a strip finding names goes.
272
+ */
273
+ async function storageRules(ctx, source, s, role, location, rewrite) {
274
+ const compressed = s.header.instance === 1;
195
275
  let inner;
196
- if (s.header.instance === 1) {
276
+ if (compressed) {
197
277
  const raw = await source.read(s.offset + 8, s.header.length);
198
278
  const declared = raw.length >= 4 ? u32(raw, 0) : 0;
199
279
  if (declared > ctx.settings.decompressedBytes)
@@ -203,8 +283,7 @@ async function storageRules(ctx, source, s, role, location) {
203
283
  bytes = zlib.inflateSync(raw.subarray(4), { maxOutputLength: Math.max(1, declared) });
204
284
  }
205
285
  catch {
206
- unreadable(ctx, role, location, 'its compressed data does not inflate to its declared size');
207
- return;
286
+ return unreadable(ctx, role, location, 'its compressed data does not inflate to its declared size');
208
287
  }
209
288
  inner = { size: async () => bytes.length, read: async (offset, length) => bytes.subarray(offset, offset + length) };
210
289
  }
@@ -223,28 +302,69 @@ async function storageRules(ctx, source, s, role, location) {
223
302
  catch (e) {
224
303
  if (!(e instanceof errors_1.StopError) || e.category !== types_1.OfficeCategory.Container)
225
304
  throw e;
226
- unreadable(ctx, role, location, `its storage does not read: ${e.message}`);
227
- return;
305
+ return unreadable(ctx, role, location, `its storage does not read: ${e.message}`);
228
306
  }
229
307
  (0, objects_1.nestedNotes)(ctx, cfb, location);
230
- if (role === 'control')
231
- ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, { storage: location, ...(cfb.root.clsid === '00000000-0000-0000-0000-000000000000' ? {} : { clsid: cfb.root.clsid }) });
232
- // An object's storage goes to the plugins as the compound file it is. The rewrite cannot change the presentation's
233
- // records, so only a plugin that passes the object keeps it in the output.
308
+ const strips = ctx.strips;
309
+ if (role === 'control') {
310
+ const data = { storage: location, ...(cfb.root.clsid === '00000000-0000-0000-0000-000000000000' ? {} : { clsid: cfb.root.clsid }) };
311
+ const problem = await (0, forms_1.controlStorageProblem)(cfb, cfb.root, forms_1.OOXML_CONTROL_STREAMS);
312
+ if (problem)
313
+ ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, { ...data, reason: (0, context_1.clip)(problem) });
314
+ else
315
+ ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.FormsControl, location, data);
316
+ // What the storage holds besides the control gets the rules any compound file gets.
317
+ await (0, objects_1.compoundRules)(ctx, cfb, location, false);
318
+ return ctx.strips > strips ? 'remove' : 'keep';
319
+ }
320
+ if (role === 'vba') {
321
+ // The project's storage is the root of the compound file, so the rules give it a plan of its own.
322
+ const own = new plan_1.CompoundPlan();
323
+ await (0, objects_1.compoundRules)(ctx, cfb, location, false, rewrite ? own : undefined);
324
+ const project = cfb.child(cfb.root, 'VBA');
325
+ if (!rewrite || !project || own.drop.has(project))
326
+ return ctx.strips > strips ? 'remove' : 'keep';
327
+ // The project stays: written again without its compiled code and whatever else its rules removed.
328
+ if (ctx.strips === strips && !own.replace.size && !own.drop.size)
329
+ return 'keep';
330
+ return { body: await storageBody(ctx, cfb, own, compressed) };
331
+ }
332
+ // An object's storage goes to the plugins as the compound file it is, and stays as they leave it.
234
333
  const size = await inner.size();
235
- const decision = role === 'object' ? await (0, contained_1.decideContained)(ctx, { location, chunks: () => (0, io_1.chunksOf)(inner, size) }) : undefined;
236
- await (0, objects_1.compoundRules)(ctx, cfb, location, role === 'object', undefined, decision);
334
+ const decision = await (0, contained_1.decideContained)(ctx, { location, chunks: () => (0, io_1.chunksOf)(inner, size) });
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);
338
+ if ((decision && !decision.keep) || ctx.strips > strips)
339
+ return 'remove';
340
+ if (decision?.replacement) {
341
+ const bytes = await (0, io_1.readAll)(decision.replacement);
342
+ return { body: compressed ? Buffer.concat([Buffer.from(le32(bytes.length)), zlib.deflateSync(bytes)]) : bytes };
343
+ }
344
+ return 'keep';
237
345
  }
346
+ /** An ExOleObjStg's data for a compound file written with `plan`: deflated after its size when it was compressed. */
347
+ async function storageBody(ctx, cfb, plan, compressed) {
348
+ const sink = (0, io_1.bufferSink)();
349
+ await (0, rewrite_cfb_1.writeCompound)(ctx.run, cfb, plan, sink);
350
+ const bytes = sink.result();
351
+ return compressed ? Buffer.concat([Buffer.from(le32(bytes.length)), zlib.deflateSync(bytes)]) : bytes;
352
+ }
353
+ const le32 = (n) => [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff];
238
354
  function unreadable(ctx, role, location, reason) {
239
- if (role === 'vba')
240
- ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, location, { storage: location, reason });
241
- else if (role === 'control')
242
- ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, { storage: location, reason });
243
- else
244
- ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, location, { storage: location, reason });
355
+ const r = role === 'vba'
356
+ ? ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.VbaProject, location, { storage: location, reason })
357
+ : role === 'control'
358
+ ? ctx.report(types_1.OfficeCategory.Control, types_1.OfficeDetail.ActiveX, location, { storage: location, reason })
359
+ : ctx.report(types_1.OfficeCategory.Embedded, types_1.OfficeDetail.OleObject, location, { storage: location, reason });
360
+ return r.action === 'strip' ? 'remove' : 'keep';
245
361
  }
246
- /** The rules of one container record, from its children: actions, hyperlinks and linked media. */
247
- async function containerRules(ctx, reader, h, offset, kids, stream) {
362
+ /**
363
+ * The rules of one container record, from its children: actions, hyperlinks and linked media. What strips goes in the
364
+ * edits: an action becomes no action, a hyperlink goes, and a linked file's path is emptied. `links` collects the ids
365
+ * of the hyperlinks that go, and `actions` every action, for the actions that open those.
366
+ */
367
+ async function containerRules(ctx, reader, h, offset, kids, stream, edits, links, actions) {
248
368
  const location = `${stream}, offset ${offset}`;
249
369
  const strings = async () => {
250
370
  const out = new Map();
@@ -257,25 +377,38 @@ async function containerRules(ctx, reader, h, offset, kids, stream) {
257
377
  const atom = kids.find(k => k.h.type === T.interactiveInfoAtom && k.h.length >= 16);
258
378
  if (!atom)
259
379
  return;
260
- const action = (await reader.read(atom.offset + 8 + 8, 1))[0];
380
+ const body = Uint8Array.from(await reader.read(atom.offset + 8, 16));
381
+ const action = body[8];
382
+ actions.push({ offset: atom.offset, hyperlink: u32(body, 4), action });
261
383
  const name = (await strings()).get(2) ?? '';
262
384
  const data = { trigger: h.instance === 1 ? 'mouse over' : 'click', ...(name ? { action: (0, context_1.clip)(name) } : {}) };
385
+ let r;
263
386
  if (action === 2)
264
- ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.RunProgram, location, data);
387
+ r = ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.RunProgram, location, data);
388
+ // A macro of a VBA project that stays keeps its action.
265
389
  else if (action === 1)
266
- ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, location, data);
390
+ r = ctx.macroTrigger(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, location, data, name);
267
391
  else if (action === 5)
268
- ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.OleVerb, location, data);
392
+ r = ctx.report(types_1.OfficeCategory.Action, types_1.OfficeDetail.OleVerb, location, data);
393
+ if (r?.action === 'strip') {
394
+ body[8] = 0;
395
+ edits.replace.set(atom.offset, body);
396
+ }
269
397
  }
270
398
  else if (h.type === T.exHyperlink) {
271
399
  const s = await strings();
272
400
  const target = s.get(1) ?? '';
273
- if (target.trim() && !(0, links_1.isFragment)(target))
274
- ctx.link(target, location, { source: 'PowerPoint hyperlink' });
401
+ const atom = kids.find(k => k.h.type === T.exHyperlinkAtom && k.h.length >= 4);
402
+ if (target.trim() && !(0, links_1.isFragment)(target) && ctx.link(target, location, { source: 'PowerPoint hyperlink' }).action === 'strip') {
403
+ edits.remove.add(offset);
404
+ if (atom)
405
+ links.set(u32(await reader.read(atom.offset + 8, 4), 0), offset);
406
+ }
275
407
  }
276
408
  else if (MEDIA_LINKS.has(h.type)) {
277
409
  const path = (await strings()).get(0) ?? '';
278
- if (path.trim())
279
- ctx.external(types_1.OfficeDetail.Other, path, location, { source: 'linked sound or movie' }, 'linked media');
410
+ const string = kids.find(k => k.h.type === T.cString && k.h.instance === 0);
411
+ if (path.trim() && ctx.external(types_1.OfficeDetail.Other, path, location, { source: 'linked sound or movie' }, 'linked media').action === 'strip' && string)
412
+ edits.replace.set(string.offset, new Uint8Array(0));
280
413
  }
281
414
  }
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/types.d.ts CHANGED
@@ -79,6 +79,7 @@ export declare enum OfficeDetail {
79
79
  UnsupportedAlgorithm = "UNSUPPORTED_ALGORITHM",
80
80
  XorObfuscation = "XOR_OBFUSCATION",
81
81
  VbaProject = "VBA_PROJECT",
82
+ CompiledCode = "COMPILED_CODE",
82
83
  XlmMacrosheet = "XLM_MACROSHEET",
83
84
  WordBasic = "WORDBASIC",
84
85
  OdfBasic = "ODF_BASIC",
@@ -132,6 +133,8 @@ export declare enum OfficeDetail {
132
133
  PluginFailed = "PLUGIN_FAILED",
133
134
  PluginRemoved = "PLUGIN_REMOVED",
134
135
  ActiveX = "ACTIVEX",
136
+ FormsControl = "FORMS_CONTROL",
137
+ ControlPicture = "CONTROL_PICTURE",
135
138
  VerificationFailed = "VERIFICATION_FAILED"
136
139
  }
137
140
  export type OfficeFindingAction = 'reject' | 'strip' | 'info';
@@ -293,6 +296,41 @@ export interface ContainedFilePlugin {
293
296
  /** Writes the bytes to keep. Returning 'removed' drops the file after all. */
294
297
  process(file: ContainedFile, sink: ByteSink, context: PluginContext): Promise<'scrubbed' | 'passed' | 'removed' | ContainedFileResult>;
295
298
  }
299
+ /** A type library or project a VBA project refers to, as its dir stream lists it. */
300
+ export interface VbaReferenceInfo {
301
+ name: string;
302
+ kind: 'registered' | 'control' | 'project';
303
+ /** A type library's LibidReference, such as `*\G{...}#2.0#0#path#description`, or a project's path. */
304
+ libid: string;
305
+ }
306
+ /** A VBA module, as office-defuse shows it to a script plugin. */
307
+ export interface ContainedScript {
308
+ /** The module's source, decoded with the project's code page. */
309
+ text: string;
310
+ /** "vba-" and the module's kind: "vba-document", "vba-standard", "vba-class" or "vba-form". */
311
+ trigger: string;
312
+ location: string;
313
+ /** The module's name. */
314
+ fieldName: string;
315
+ language: 'vba';
316
+ form: 'office';
317
+ /** The references of the module's project. office-defuse gives them with the project's first module only. */
318
+ references?: VbaReferenceInfo[];
319
+ }
320
+ export interface ScriptPlugin {
321
+ kind: 'script';
322
+ name: string;
323
+ accepts(script: ContainedScript): boolean | Promise<boolean>;
324
+ /**
325
+ * 'passed' keeps the module. Anything else removes the VBA project, since office-defuse writes no module a plugin
326
+ * rewrote. `findings` go into the report at the module's location.
327
+ */
328
+ process(script: ContainedScript): Promise<{
329
+ result: 'scrubbed' | 'passed' | 'removed';
330
+ text?: string;
331
+ findings?: DefuseFinding[];
332
+ }>;
333
+ }
296
334
  export interface OfficeActionOverride {
297
335
  category: OfficeCategory;
298
336
  detail?: OfficeDetail;
@@ -323,6 +361,12 @@ export interface OfficeOptions {
323
361
  scoreBands?: OfficeScoreBands;
324
362
  /** Plugins that decide about the files inside the file: embedded objects and packages, OLE parts and attachments. */
325
363
  filePlugins?: ContainedFilePlugin[];
364
+ /**
365
+ * Plugins that decide about each module of a VBA project in a compound file or an OOXML package. The first plugin that
366
+ * accepts a module decides it. The project stays when every module passed, written without its compiled code so
367
+ * Office compiles the source the plugins read. Otherwise it goes, as it does without plugins.
368
+ */
369
+ scriptPlugins?: ScriptPlugin[];
326
370
  /** Directory for temporary files. Defaults to os.tmpdir(). */
327
371
  tempDir?: string;
328
372
  /**
package/dist/types.js CHANGED
@@ -90,6 +90,7 @@ var OfficeDetail;
90
90
  OfficeDetail["XorObfuscation"] = "XOR_OBFUSCATION";
91
91
  // MACRO
92
92
  OfficeDetail["VbaProject"] = "VBA_PROJECT";
93
+ OfficeDetail["CompiledCode"] = "COMPILED_CODE";
93
94
  OfficeDetail["XlmMacrosheet"] = "XLM_MACROSHEET";
94
95
  OfficeDetail["WordBasic"] = "WORDBASIC";
95
96
  OfficeDetail["OdfBasic"] = "ODF_BASIC";
@@ -151,6 +152,8 @@ var OfficeDetail;
151
152
  OfficeDetail["PluginRemoved"] = "PLUGIN_REMOVED";
152
153
  // CONTROL
153
154
  OfficeDetail["ActiveX"] = "ACTIVEX";
155
+ OfficeDetail["FormsControl"] = "FORMS_CONTROL";
156
+ OfficeDetail["ControlPicture"] = "CONTROL_PICTURE";
154
157
  // PROCESSING
155
158
  OfficeDetail["VerificationFailed"] = "VERIFICATION_FAILED";
156
159
  })(OfficeDetail || (exports.OfficeDetail = OfficeDetail = {}));