@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/dist/xls.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type BiffRecord } from './biff';
2
2
  import type { CfbEntry, CompoundFile } from './cfb';
3
3
  import { type RunContext } from './context';
4
+ import type { CompoundPlan } from './plan';
4
5
  /** Turns an encrypted record body into its plain bytes. Without encryption the body is as stored. */
5
6
  export type Decrypt = (r: BiffRecord) => Uint8Array;
6
7
  /**
@@ -23,7 +24,14 @@ export declare function workbookKind(cfb: CompoundFile, stream: CfbEntry, deadli
23
24
  * names Excel runs as macros, and the DDE, OLE and workbook links. The sheets give shapes that run macros and
24
25
  * hyperlinks. An encrypted workbook goes through `unlock`.
25
26
  */
26
- export declare function excelRules(ctx: RunContext, cfb: CompoundFile, stream: CfbEntry, unlock: Unlock): Promise<void>;
27
+ /** An ActiveX control a sheet's Obj record keeps in the Ctls stream: its ProgID and where its data is. */
28
+ export interface CtlsControl {
29
+ progId: string;
30
+ position: number;
31
+ size: number;
32
+ sheet: string;
33
+ }
34
+ export declare function excelRules(ctx: RunContext, cfb: CompoundFile, stream: CfbEntry, unlock: Unlock, plan?: CompoundPlan, controls?: CtlsControl[]): Promise<void>;
27
35
  /**
28
36
  * A VirtualPath (MS-XLS 2.5.277) written out. One that starts with U+0001 is encoded: its control characters name a
29
37
  * volume, the root, a folder, a step up, a URL's volume or Excel's startup folders.
package/dist/xls.js CHANGED
@@ -8,9 +8,11 @@ const biff_1 = require("./biff");
8
8
  const context_1 = require("./context");
9
9
  const errors_1 = require("./errors");
10
10
  const formulas_1 = require("./formulas");
11
+ const io_1 = require("./io");
11
12
  const links_1 = require("./links");
12
13
  const oleobj_1 = require("./oleobj");
13
14
  const types_1 = require("./types");
15
+ const xls_writer_1 = require("./xls-writer");
14
16
  const STATES = ['visible', 'hidden', 'very hidden', 'hidden'];
15
17
  /** Record types of cell formulas (MS-XLS 2.4.127, 2.4.260 and 2.4.4): FORMULA, SHRFMLA and ARRAY. */
16
18
  const FORMULA = 0x0006;
@@ -41,13 +43,7 @@ async function workbookKind(cfb, stream, deadline) {
41
43
  }
42
44
  return kind;
43
45
  }
44
- /**
45
- * Inspects an Excel 5.0 to 2003 workbook stream. Excel reads the globals substream from the start of the stream,
46
- * then each sheet from where its BoundSheet record says the sheet starts. The globals give the macro sheets, the
47
- * names Excel runs as macros, and the DDE, OLE and workbook links. The sheets give shapes that run macros and
48
- * hyperlinks. An encrypted workbook goes through `unlock`.
49
- */
50
- async function excelRules(ctx, cfb, stream, unlock) {
46
+ async function excelRules(ctx, cfb, stream, unlock, plan, controls = []) {
51
47
  const location = stream.name;
52
48
  const records = new biff_1.RecordReader(cfb, stream, 0, ctx.settings.deadline);
53
49
  const first = await records.next();
@@ -60,24 +56,32 @@ async function excelRules(ctx, cfb, stream, unlock) {
60
56
  const names = [];
61
57
  /** The SupBook each XTI of the ExternSheet record names. */
62
58
  const xti = [];
59
+ const edits = (0, xls_writer_1.noEdits)();
60
+ /** Names that run a macro and go, by their Lbl record, given new names once every name is known. */
61
+ const renames = [];
63
62
  for (let r = await records.next(); r && r.type !== biff_1.R.eof; r = await records.next()) {
64
63
  if (r.type === biff_1.R.filePass) {
64
+ const before = ctx.findings.length;
65
65
  decrypt = await unlock(ctx, r, location);
66
+ // The rewrite writes the workbook decrypted, so one VelvetSweatshop opens is rewritten when that finding strips.
67
+ if (ctx.findings.slice(before).some(f => f.category === types_1.OfficeCategory.Encrypted && f.detail === types_1.OfficeDetail.DefaultPassword && f.action === 'strip'))
68
+ edits.decrypt = true;
66
69
  continue;
67
70
  }
68
71
  const body = decrypt(r);
69
72
  if (r.type === biff_1.R.boundSheet)
70
- sheets.push((0, biff_1.parseBoundSheet)(body, biff8));
73
+ sheets.push({ ...(0, biff_1.parseBoundSheet)(body, biff8), record: r.offset });
71
74
  else if (r.type === biff_1.R.lbl) {
72
75
  const name = (0, biff_1.parseLbl)(body, biff8);
73
76
  names.push(name.name);
74
77
  const auto = (0, biff_1.autoName)(name.name);
75
- if (auto)
76
- ctx.report(types_1.OfficeCategory.Macro, auto.onOpen ? types_1.OfficeDetail.EventOnOpen : types_1.OfficeDetail.Event, location, { event: auto.event, name: (0, context_1.clip)(name.name) });
78
+ // Removing the name would renumber the names after it, which formulas refer to by number, so it is renamed.
79
+ if (auto && ctx.report(types_1.OfficeCategory.Macro, auto.onOpen ? types_1.OfficeDetail.EventOnOpen : types_1.OfficeDetail.Event, location, { event: auto.event, name: (0, context_1.clip)(name.name) }).action === 'strip')
80
+ renames.push({ offset: r.offset, event: auto.event });
77
81
  }
78
82
  else if (r.type === biff_1.R.supBook && body.length >= 4) {
79
83
  const cch = body[2] | (body[3] << 8);
80
- books.push({ ctab: body[0] | (body[1] << 8), cch, path: cch > 0 && cch <= 0xff ? (0, biff_1.xlString)(body, 4, cch).text : '', ole: false, dde: false, names: [] });
84
+ books.push({ ctab: body[0] | (body[1] << 8), cch, path: cch > 0 && cch <= 0xff ? (0, biff_1.xlString)(body, 4, cch).text : '', ole: false, dde: false, names: [], offset: r.offset });
81
85
  }
82
86
  else if (r.type === biff_1.R.externSheet && biff8 && body.length >= 2) {
83
87
  const n = body[0] | (body[1] << 8);
@@ -95,31 +99,64 @@ async function excelRules(ctx, cfb, stream, unlock) {
95
99
  books[books.length - 1].dde = true;
96
100
  }
97
101
  }
102
+ // A link that goes keeps its SupBook, which formulas count to by index, with a path to the workbook itself.
98
103
  for (const b of books)
99
- linkRules(ctx, b, location);
104
+ if (linkRules(ctx, b, location)?.action === 'strip')
105
+ edits.selfBooks.add(b.offset);
100
106
  const nameOf = (ref) => (ref.kind === 'name' ? names[ref.index - 1] : books[xti[ref.ixti] ?? -1]?.names[ref.index - 1]);
107
+ // A macro of the workbook's own: an external name of the SupBook that is the workbook itself, as Excel names a VBA
108
+ // macro a button runs. A defined name may stand for anything, so it is not one.
109
+ const ownName = (ref) => (ref.kind === 'namex' && books[xti[ref.ixti] ?? -1]?.cch === 0x401 ? nameOf(ref) : undefined);
101
110
  for (const s of sheets)
102
- await sheetRules(ctx, cfb, stream, s, decrypt, location, biff8 ? nameOf : undefined);
111
+ await sheetRules(ctx, cfb, stream, s, decrypt, location, edits, controls, biff8 ? nameOf : undefined, biff8 ? ownName : undefined);
112
+ const taken = new Set(names.map(n => n.toLowerCase()));
113
+ for (const { offset, event } of renames) {
114
+ let k = 1;
115
+ while (taken.has(`removed_${event}_${k}`.toLowerCase()))
116
+ k++;
117
+ const name = `Removed_${event}_${k}`;
118
+ taken.add(name.toLowerCase());
119
+ edits.names.set(offset, name);
120
+ }
121
+ // BIFF5 has no SupBook, HLink or Obj macro records in the shape BIFF8 has, so only its sheets and names change. What
122
+ // else strips stays, and the check of the output says so.
123
+ if (!biff8) {
124
+ edits.objMacros.clear();
125
+ edits.selfBooks.clear();
126
+ edits.hyperlinks.clear();
127
+ }
128
+ if (!plan || !(0, xls_writer_1.hasEdits)(edits))
129
+ return;
130
+ const sink = new io_1.SpillSink(ctx.run.temp, ctx.run.threshold);
131
+ ctx.run.held.push(sink);
132
+ try {
133
+ await (0, xls_writer_1.rewriteWorkbook)(cfb, stream, edits, biff8, decrypt, sink, ctx.settings.deadline);
134
+ await sink.close();
135
+ plan.replace.set(stream, sink);
136
+ }
137
+ catch (e) {
138
+ // Left as it is, the stream still holds what the findings name, and the check of the output says so.
139
+ if (!(e instanceof xls_writer_1.RewriteError))
140
+ throw e;
141
+ }
103
142
  }
104
- /** A SupBook record, read as MS-XLS 2.4.271 and oletools tell its kinds apart. */
143
+ /**
144
+ * A SupBook record, read as MS-XLS 2.4.271 and oletools tell its kinds apart. A path that starts with U+0002 or U+0003
145
+ * refers to the workbook itself, as LibreOffice reads it.
146
+ */
105
147
  function linkRules(ctx, b, location) {
106
- if (b.cch === 0x401 || b.cch === 0x3a01 || b.path === ' ' || b.path === '\0' || !b.path)
107
- return;
148
+ if (b.cch === 0x401 || b.cch === 0x3a01 || b.path === ' ' || b.path === '\0' || !b.path || b.path.charCodeAt(0) === 2 || b.path.charCodeAt(0) === 3)
149
+ return undefined;
108
150
  if (b.ctab === 0) {
109
151
  // A DDE or OLE data source: the application or class, U+0003, and the topic or file.
110
152
  const [application, topic = ''] = b.path.split('\x03');
111
153
  if (b.ole && !b.dde) {
112
154
  const data = { source: 'SupBook', className: (0, context_1.clip)(application) };
113
- if (topic)
114
- ctx.external(types_1.OfficeDetail.Object, topic, location, data, 'linked object');
115
- else
116
- ctx.report(types_1.OfficeCategory.External, types_1.OfficeDetail.Object, location, data);
155
+ return topic ? ctx.external(types_1.OfficeDetail.Object, topic, location, data, 'linked object') : ctx.report(types_1.OfficeCategory.External, types_1.OfficeDetail.Object, location, data);
117
156
  }
118
- else
119
- ctx.report(types_1.OfficeCategory.Dde, types_1.OfficeDetail.Link, location, { application: (0, context_1.clip)(application), topic: (0, context_1.clip)(topic) });
120
- return;
157
+ return ctx.report(types_1.OfficeCategory.Dde, types_1.OfficeDetail.Link, location, { application: (0, context_1.clip)(application), topic: (0, context_1.clip)(topic) });
121
158
  }
122
- ctx.external(types_1.OfficeDetail.Workbook, virtualPath(b.path), location, { source: 'SupBook' }, 'external workbook');
159
+ return ctx.external(types_1.OfficeDetail.Workbook, virtualPath(b.path), location, { source: 'SupBook' }, 'external workbook');
123
160
  }
124
161
  /**
125
162
  * A VirtualPath (MS-XLS 2.5.277) written out. One that starts with U+0001 is encoded: its control characters name a
@@ -152,7 +189,7 @@ function virtualPath(p) {
152
189
  return out;
153
190
  }
154
191
  /** One sheet's substream, with any chart substreams inside it: Obj records that run macros, and hyperlinks. */
155
- async function sheetRules(ctx, cfb, stream, s, decrypt, location, nameOf) {
192
+ async function sheetRules(ctx, cfb, stream, s, decrypt, location, edits, controls, nameOf, ownName) {
156
193
  const records = new biff_1.RecordReader(cfb, stream, s.position, ctx.settings.deadline);
157
194
  const bof = await records.next();
158
195
  if (bof?.type !== biff_1.R.bof || bof.body.length < 4)
@@ -160,8 +197,9 @@ async function sheetRules(ctx, cfb, stream, s, decrypt, location, nameOf) {
160
197
  const bofType = bof.body[2] | (bof.body[3] << 8);
161
198
  // The sheet's own BOF says what it is, and so does its BoundSheet record. Either one makes a macro sheet.
162
199
  const macroSheet = s.type === 1 || bofType === 0x0040;
163
- if (macroSheet)
164
- ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, location, { sheet: (0, context_1.clip)(s.name), state: STATES[s.state] });
200
+ // A macro sheet becomes an empty worksheet of the same name, so the sheets after it keep their indexes.
201
+ if (macroSheet && ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.XlmMacrosheet, location, { sheet: (0, context_1.clip)(s.name), state: STATES[s.state] }).action === 'strip' && s.record !== undefined)
202
+ edits.emptySheets.add(s.record);
165
203
  let depth = 1;
166
204
  for (let r = await records.next(); r && depth > 0; r = await records.next()) {
167
205
  if (r.type === biff_1.R.bof)
@@ -169,13 +207,23 @@ async function sheetRules(ctx, cfb, stream, s, decrypt, location, nameOf) {
169
207
  else if (r.type === biff_1.R.eof)
170
208
  depth--;
171
209
  else if (r.type === biff_1.R.obj) {
172
- if (objMacro(decrypt(r)))
173
- ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, location, { sheet: (0, context_1.clip)(s.name) });
210
+ const body = decrypt(r);
211
+ const control = ctlsControl(body);
212
+ if (control)
213
+ controls.push({ ...control, sheet: s.name });
214
+ const macro = objMacro(body);
215
+ if (!macro)
216
+ continue;
217
+ // A shape that runs one of the workbook's own macros stays when its VBA project does.
218
+ const name = macro.ref && ownName ? ownName(macro.ref) : undefined;
219
+ const data = { sheet: (0, context_1.clip)(s.name), ...(macro.ref && nameOf ? { macro: (0, context_1.clip)(nameOf(macro.ref) ?? '') } : {}) };
220
+ if (ctx.report(types_1.OfficeCategory.Macro, types_1.OfficeDetail.Link, location, data, name === undefined ? undefined : (0, context_1.ownMacro)(ctx.run, name)).action === 'strip')
221
+ edits.objMacros.add(r.offset);
174
222
  }
175
223
  else if (r.type === biff_1.R.hlink) {
176
224
  const target = hyperlink(decrypt(r));
177
- if (target.trim() && !(0, links_1.isFragment)(target))
178
- ctx.link(target, location, { sheet: (0, context_1.clip)(s.name), source: 'HLink record' });
225
+ if (target.trim() && !(0, links_1.isFragment)(target) && ctx.link(target, location, { sheet: (0, context_1.clip)(s.name), source: 'HLink record' }).action === 'strip')
226
+ edits.hyperlinks.add(r.offset);
179
227
  }
180
228
  else if (nameOf && !macroSheet && FORMULA_AT.has(r.type)) {
181
229
  // A macro sheet's formulas are its macros, which its own finding covers.
@@ -189,20 +237,55 @@ async function sheetRules(ctx, cfb, stream, s, decrypt, location, nameOf) {
189
237
  }
190
238
  }
191
239
  }
192
- /** Whether an Obj record (MS-XLS 2.4.181) holds an FtMacro subrecord, the macro a click runs. */
240
+ /**
241
+ * The FtMacro subrecord of an Obj record (MS-XLS 2.4.181), the macro a click runs, with the name its formula refers
242
+ * to when the formula is one PtgName or PtgNameX and nothing else. Undefined when the record has none.
243
+ */
193
244
  function objMacro(b) {
194
- let at = 0;
195
- while (at + 4 <= b.length) {
196
- const ft = b[at] | (b[at + 1] << 8);
197
- const cb = b[at + 2] | (b[at + 3] << 8);
198
- // FtEnd ends the list, and FtLbsData's length does not count all its data, so the walk stops there.
199
- if (ft === 0x00 || ft === 0x13)
200
- return false;
201
- if (ft === 0x04)
202
- return cb > 0;
203
- at += 4 + cb;
245
+ for (const { ft, data: f } of (0, biff_1.objSubrecords)(b)) {
246
+ if (ft !== 0x04)
247
+ continue;
248
+ if (f.length === 0)
249
+ return undefined;
250
+ // As Excel writes it: cce in 15 bits, 4 unused bytes, and the tokens. PtgName and PtgNameX are classed tokens,
251
+ // 0x23, 0x43 or 0x63 and 0x39, 0x59 or 0x79; a base token below 0x20 shares their low bits and is neither.
252
+ const cce = f.length >= 2 ? (f[0] | (f[1] << 8)) & 0x7fff : 0;
253
+ const rgce = f.subarray(6, 6 + cce);
254
+ const ptg = rgce[0] ?? 0;
255
+ const u32 = (k) => (rgce[k] | (rgce[k + 1] << 8) | (rgce[k + 2] << 16) | (rgce[k + 3] << 24)) >>> 0;
256
+ if (ptg >= 0x20 && (ptg & 0x1f) === 0x03 && cce === 5 && rgce.length === 5)
257
+ return { ref: { kind: 'name', index: u32(1) } };
258
+ if (ptg >= 0x20 && (ptg & 0x1f) === 0x19 && cce === 7 && rgce.length === 7)
259
+ return { ref: { kind: 'namex', ixti: rgce[1] | (rgce[2] << 8), index: u32(3) } };
260
+ return {};
261
+ }
262
+ return undefined;
263
+ }
264
+ /**
265
+ * The control an Obj record keeps in the Ctls stream: FtPioGrbit says an ActiveX control (fCtl) whose data is in Ctls
266
+ * (fPrstm), and FtPictFmla gives its ProgID, in the formula's embedInfo, and its place in Ctls after the formula.
267
+ */
268
+ function ctlsControl(b) {
269
+ let flags = 0;
270
+ for (const { ft, data: d } of (0, biff_1.objSubrecords)(b)) {
271
+ if (ft === 0x08 && d.length >= 2)
272
+ flags = d[0] | (d[1] << 8);
273
+ if (ft !== 0x09 || (flags & 0x30) !== 0x30 || d.length < 2)
274
+ continue;
275
+ // ObjFmla: cbFmla, then cce, 4 unused bytes, the tokens, and PictFmlaEmbedInfo: 0x03, cbClass, a reserved byte
276
+ // and the class name. lPosInCtlStm and cbBufInCtlStm follow.
277
+ const cbFmla = d[0] | (d[1] << 8);
278
+ const f = d.subarray(2, 2 + cbFmla);
279
+ const cce = f.length >= 2 ? (f[0] | (f[1] << 8)) & 0x7fff : 0;
280
+ const info = f.subarray(6 + cce);
281
+ const progId = info[0] === 0x03 && info.length >= 4 ? (0, biff_1.xlString)(info, 3, info[1]).text : '';
282
+ const after = 2 + cbFmla;
283
+ if (after + 8 > d.length)
284
+ return undefined;
285
+ const u32 = (k) => (d[k] | (d[k + 1] << 8) | (d[k + 2] << 16) | (d[k + 3] << 24)) >>> 0;
286
+ return { progId, position: u32(after), size: u32(after + 4) };
204
287
  }
205
- return false;
288
+ return undefined;
206
289
  }
207
290
  /** The target of an HLink record: its Hyperlink Object (MS-OSHARED 2.3.7.1) after the cell range and class id. */
208
291
  function hyperlink(b) {
package/dist/xml.d.ts CHANGED
@@ -159,6 +159,9 @@ export declare class XmlTokenizer {
159
159
  /** The XML declaration says standalone="yes". */
160
160
  private standalone;
161
161
  private encoding;
162
+ /** Set by a declaration of US-ASCII, which agrees with UTF-8 only while every byte is ASCII. */
163
+ private asciiDeclared;
164
+ private nonAscii;
162
165
  /** Start tags read so far, which numbers the elements. */
163
166
  private elements;
164
167
  /** An attribute value of the tag being read referred to an entity of the internal subset. */
package/dist/xml.js CHANGED
@@ -190,6 +190,9 @@ class XmlTokenizer {
190
190
  /** The XML declaration says standalone="yes". */
191
191
  standalone = false;
192
192
  encoding = 'utf-8';
193
+ /** Set by a declaration of US-ASCII, which agrees with UTF-8 only while every byte is ASCII. */
194
+ asciiDeclared = false;
195
+ nonAscii = false;
193
196
  /** Start tags read so far, which numbers the elements. */
194
197
  elements = 0;
195
198
  /** An attribute value of the tag being read referred to an entity of the internal subset. */
@@ -263,6 +266,14 @@ class XmlTokenizer {
263
266
  return decoder;
264
267
  }
265
268
  decode(decoder, bytes, more) {
269
+ if (!this.nonAscii)
270
+ for (const c of bytes)
271
+ if (c >= 0x80) {
272
+ this.nonAscii = true;
273
+ break;
274
+ }
275
+ if (this.asciiDeclared && this.nonAscii)
276
+ this.stop(types_1.OfficeDetail.Encoding, 'The declaration says us-ascii, the bytes are not all ASCII');
266
277
  try {
267
278
  return decoder.decode(bytes, { stream: more });
268
279
  }
@@ -279,9 +290,16 @@ class XmlTokenizer {
279
290
  const declared = /\bencoding\s*=\s*(["'])([^"']*)\1/.exec(body)?.[2]?.toLowerCase();
280
291
  if (declared === undefined)
281
292
  return;
282
- const ok = this.encoding === 'utf-8' ? declared === 'utf-8' || declared === 'utf8' : declared === 'utf-16' || declared === this.encoding;
293
+ // SharePoint declares us-ascii on the custom XML it writes. ASCII bytes read the same in UTF-8.
294
+ const ascii = this.encoding === 'utf-8' && (declared === 'us-ascii' || declared === 'ascii');
295
+ const ok = this.encoding === 'utf-8' ? declared === 'utf-8' || declared === 'utf8' || ascii : declared === 'utf-16' || declared === this.encoding;
283
296
  if (!ok)
284
297
  this.stop(types_1.OfficeDetail.Encoding, `The declaration says ${declared}, the bytes are ${this.encoding.toUpperCase()}`);
298
+ if (ascii) {
299
+ this.asciiDeclared = true;
300
+ if (this.nonAscii)
301
+ this.stop(types_1.OfficeDetail.Encoding, 'The declaration says us-ascii, the bytes are not all ASCII');
302
+ }
285
303
  }
286
304
  feed(text, final) {
287
305
  this.buf = this.pos < this.buf.length ? this.buf.slice(this.pos) + text : text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/office-defuse",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "Inspect Office and OpenDocument files for VBA and Excel 4.0 macros, LibreOffice Basic and scripts, DDE, external templates and links, embedded OLE objects and ActiveX controls, and score the risk. Strict ZIP and XML readers, bounded memory, no runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "patrickdk77",