@patrickdk77/office-defuse 0.1.0 → 0.2.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.
Files changed (88) hide show
  1. package/README.md +278 -36
  2. package/dist/biff.d.ts +62 -0
  3. package/dist/biff.js +95 -0
  4. package/dist/cfb-writer.d.ts +25 -0
  5. package/dist/cfb-writer.js +314 -0
  6. package/dist/cfb.d.ts +58 -0
  7. package/dist/cfb.js +456 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +357 -0
  10. package/dist/contained.d.ts +46 -0
  11. package/dist/contained.js +152 -0
  12. package/dist/context.d.ts +140 -17
  13. package/dist/context.js +135 -37
  14. package/dist/crc32.d.ts +8 -2
  15. package/dist/crc32.js +44 -4
  16. package/dist/engine.d.ts +12 -1
  17. package/dist/engine.js +336 -42
  18. package/dist/errors.d.ts +17 -0
  19. package/dist/errors.js +27 -1
  20. package/dist/fields.d.ts +36 -0
  21. package/dist/fields.js +139 -0
  22. package/dist/findings.d.ts +2 -1
  23. package/dist/findings.js +44 -9
  24. package/dist/flat.d.ts +33 -0
  25. package/dist/flat.js +486 -0
  26. package/dist/formats.d.ts +7 -0
  27. package/dist/formats.js +5 -1
  28. package/dist/formulas.d.ts +27 -0
  29. package/dist/formulas.js +157 -1
  30. package/dist/index.d.ts +3 -2
  31. package/dist/index.js +8 -1
  32. package/dist/index.mjs +1 -1
  33. package/dist/io.d.ts +62 -1
  34. package/dist/io.js +245 -1
  35. package/dist/links.d.ts +27 -0
  36. package/dist/links.js +106 -1
  37. package/dist/msg.d.ts +8 -0
  38. package/dist/msg.js +251 -0
  39. package/dist/objects.d.ts +45 -0
  40. package/dist/objects.js +204 -0
  41. package/dist/odf.d.ts +66 -2
  42. package/dist/odf.js +181 -64
  43. package/dist/offcrypto.d.ts +54 -0
  44. package/dist/offcrypto.js +647 -0
  45. package/dist/ole.d.ts +12 -0
  46. package/dist/ole.js +139 -0
  47. package/dist/oleobj.d.ts +85 -0
  48. package/dist/oleobj.js +215 -0
  49. package/dist/ooxml.d.ts +83 -2
  50. package/dist/ooxml.js +358 -145
  51. package/dist/package.d.ts +3 -0
  52. package/dist/package.js +21 -0
  53. package/dist/plan.d.ts +104 -0
  54. package/dist/plan.js +73 -0
  55. package/dist/plugins.d.ts +8 -0
  56. package/dist/plugins.js +60 -0
  57. package/dist/ppt.d.ts +8 -0
  58. package/dist/ppt.js +281 -0
  59. package/dist/rewrite-cfb.d.ts +14 -0
  60. package/dist/rewrite-cfb.js +52 -0
  61. package/dist/rewrite-rtf.d.ts +9 -0
  62. package/dist/rewrite-rtf.js +45 -0
  63. package/dist/rewrite-xml.d.ts +36 -0
  64. package/dist/rewrite-xml.js +341 -0
  65. package/dist/rewrite-zip.d.ts +8 -0
  66. package/dist/rewrite-zip.js +498 -0
  67. package/dist/rtf.d.ts +8 -0
  68. package/dist/rtf.js +712 -0
  69. package/dist/rtfcomp.d.ts +7 -0
  70. package/dist/rtfcomp.js +80 -0
  71. package/dist/score.js +1 -1
  72. package/dist/sniff.d.ts +5 -0
  73. package/dist/sniff.js +38 -0
  74. package/dist/types.d.ts +66 -2
  75. package/dist/types.js +33 -0
  76. package/dist/vsd.d.ts +13 -0
  77. package/dist/vsd.js +689 -0
  78. package/dist/word.d.ts +9 -0
  79. package/dist/word.js +194 -0
  80. package/dist/xls.d.ts +27 -0
  81. package/dist/xls.js +208 -0
  82. package/dist/xml.d.ts +30 -0
  83. package/dist/xml.js +45 -23
  84. package/dist/zip-writer.d.ts +4 -0
  85. package/dist/zip-writer.js +20 -7
  86. package/dist/zip.d.ts +9 -2
  87. package/dist/zip.js +61 -4
  88. package/package.json +6 -3
@@ -76,6 +76,10 @@ class ZipWriter {
76
76
  this.sink = sink;
77
77
  this.options = options;
78
78
  }
79
+ /** Bytes written so far: where the next entry's local header goes. */
80
+ get position() {
81
+ return this.offset;
82
+ }
79
83
  async put(b) {
80
84
  await this.sink.write(b);
81
85
  this.offset += b.length;
@@ -89,15 +93,11 @@ class ZipWriter {
89
93
  yield body;
90
94
  })());
91
95
  }
92
- /** Adds an entry whose compressed bytes come from `chunks` unchanged, such as an entry of the archive being rewritten. */
93
- async copy(info, chunks) {
94
- if (this.names.has(info.name))
95
- throw new Error(`Entry ${info.name} is already in the archive`);
96
- this.names.add(info.name);
96
+ /** The local header `copy` writes for an entry. */
97
+ localHeader(info) {
97
98
  const name = Buffer.from(info.name, 'utf8');
98
99
  const flags = /^[\x20-\x7e]*$/.test(info.name) ? 0 : 0x0800;
99
100
  const big = this.options.forceZip64 === true || info.size >= MAX32 || info.compressedSize >= MAX32;
100
- const localOffset = this.offset;
101
101
  const time = info.dosTime ?? 0;
102
102
  const date = info.dosDate ?? DOS_DATE;
103
103
  const local = new Builder()
@@ -115,7 +115,20 @@ class ZipWriter {
115
115
  .bytes(name);
116
116
  if (big)
117
117
  local.u16(0x0001).u16(16).u64(info.size).u64(info.compressedSize);
118
- await this.put(local.build());
118
+ return local.build();
119
+ }
120
+ /** Adds an entry whose compressed bytes come from `chunks` unchanged, such as an entry of the archive being rewritten. */
121
+ async copy(info, chunks) {
122
+ if (this.names.has(info.name))
123
+ throw new Error(`Entry ${info.name} is already in the archive`);
124
+ this.names.add(info.name);
125
+ const name = Buffer.from(info.name, 'utf8');
126
+ const flags = /^[\x20-\x7e]*$/.test(info.name) ? 0 : 0x0800;
127
+ const big = this.options.forceZip64 === true || info.size >= MAX32 || info.compressedSize >= MAX32;
128
+ const localOffset = this.offset;
129
+ const time = info.dosTime ?? 0;
130
+ const date = info.dosDate ?? DOS_DATE;
131
+ await this.put(this.localHeader(info));
119
132
  let written = 0;
120
133
  for await (const c of chunks) {
121
134
  written += c.length;
package/dist/zip.d.ts CHANGED
@@ -27,10 +27,17 @@ export interface ZipEntry {
27
27
  }
28
28
  /** Something the reader saw that it does not refuse. Each is a strip finding, since the rewrite drops the bytes. */
29
29
  export interface ZipNote {
30
- detail: D.LeadingBytes | D.HiddenData | D.TrailingBytes;
30
+ detail: D.LeadingBytes | D.HiddenData | D.HiddenEntry | D.TrailingBytes;
31
31
  bytes: number;
32
+ /** For a hidden entry, where its local header sits. */
33
+ offset?: number;
32
34
  }
33
- /** The key two names collide on: percent-decoded, NFC and lowercase, without a directory's slash. */
35
+ /**
36
+ * The key two names collide on: percent-decoded, NFKC and case-folded, without a directory's slash, and without the
37
+ * dots and spaces Windows drops from the end of each segment. Upper then lower case is the full folding, which takes a
38
+ * sharp s to ss as macOS's case-insensitive file system does. NFKC also joins compatibility forms such as ligatures
39
+ * and full-width letters.
40
+ */
34
41
  export declare function nameKey(name: string): string;
35
42
  /**
36
43
  * A ZIP archive read from its central directory, after every local header was checked against it. Reading an entry
package/dist/zip.js CHANGED
@@ -56,6 +56,22 @@ const END_SEARCH = 22 + MAX16;
56
56
  const FLAG_MASK = 0x0001 | 0x0008 | 0x0040 | 0x0800 | 0x2000;
57
57
  const ENCRYPTION_FLAGS = 0x0001 | 0x0040 | 0x2000;
58
58
  const MAX_NAME_BYTES = 1024;
59
+ const LOCAL_SIGNATURE = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
60
+ /** The offset of the first local file header signature in [start, end) of the file, or undefined. */
61
+ async function findLocalHeader(reader, start, end, deadline) {
62
+ let carry = Buffer.alloc(0);
63
+ let at = start;
64
+ for await (const c of reader.chunks(start, end - start)) {
65
+ (0, errors_1.checkTime)(deadline);
66
+ const b = Buffer.concat([carry, Buffer.from(c.buffer, c.byteOffset, c.byteLength)]);
67
+ const i = b.indexOf(LOCAL_SIGNATURE);
68
+ if (i >= 0)
69
+ return at - carry.length + i;
70
+ carry = b.subarray(Math.max(0, b.length - 3));
71
+ at += c.length;
72
+ }
73
+ return undefined;
74
+ }
59
75
  const fail = (detail, message, data) => {
60
76
  throw new errors_1.StopError(types_1.OfficeCategory.Container, detail, message, data);
61
77
  };
@@ -137,6 +153,20 @@ function checkUnicodePath(extras, name) {
137
153
  if (other !== name)
138
154
  fail(types_1.OfficeDetail.NameEncoding, 'The Unicode path field names a different entry', { name });
139
155
  }
156
+ /** True for a segment of nothing but dots and spaces, the empty one included. */
157
+ function onlyDotsAndSpaces(seg) {
158
+ for (const c of seg)
159
+ if (c !== '.' && c !== ' ')
160
+ return false;
161
+ return true;
162
+ }
163
+ /** A segment without the dots and spaces Windows drops from its end. A loop, since /[. ]+$/ backtracks. */
164
+ function trimSegment(seg) {
165
+ let end = seg.length;
166
+ while (end > 0 && (seg[end - 1] === '.' || seg[end - 1] === ' '))
167
+ end--;
168
+ return seg.slice(0, end);
169
+ }
140
170
  function checkName(name, directory) {
141
171
  const why = (reason) => fail(types_1.OfficeDetail.UnsafeName, `Entry name ${reason}`, { name, reason });
142
172
  if (name === '')
@@ -150,11 +180,17 @@ function checkName(name, directory) {
150
180
  if (/^[A-Za-z]:/.test(name))
151
181
  why('starts with a drive letter');
152
182
  for (const seg of (directory ? name.slice(0, -1) : name).split('/')) {
153
- if (seg === '' || seg === '.' || seg === '..')
154
- why('has an empty, "." or ".." segment');
183
+ // Windows drops trailing dots and spaces from each segment, so ".. " and "..." climb out as ".." does.
184
+ if (onlyDotsAndSpaces(seg))
185
+ why('has an empty segment, or one of only dots and spaces');
155
186
  }
156
187
  }
157
- /** The key two names collide on: percent-decoded, NFC and lowercase, without a directory's slash. */
188
+ /**
189
+ * The key two names collide on: percent-decoded, NFKC and case-folded, without a directory's slash, and without the
190
+ * dots and spaces Windows drops from the end of each segment. Upper then lower case is the full folding, which takes a
191
+ * sharp s to ss as macOS's case-insensitive file system does. NFKC also joins compatibility forms such as ligatures
192
+ * and full-width letters.
193
+ */
158
194
  function nameKey(name) {
159
195
  let n = name;
160
196
  try {
@@ -163,7 +199,7 @@ function nameKey(name) {
163
199
  catch {
164
200
  // Not valid percent-encoding, so it stays as written.
165
201
  }
166
- return n.normalize('NFC').toLowerCase().replace(/\/$/, '');
202
+ return n.normalize('NFKC').toUpperCase().toLowerCase().replace(/\/$/, '').split('/').map(trimSegment).join('/');
167
203
  }
168
204
  /** ZIP64 values the central directory defers to its extra field, in the order the format fixes. */
169
205
  function zip64Central(extras, fields, name) {
@@ -371,25 +407,46 @@ class ZipArchive {
371
407
  (0, errors_1.checkTime)(deadline);
372
408
  await ZipArchive.checkLocal(reader, e, raws[i], dirOffset);
373
409
  }
410
+ // A reader that walks the file cannot know where a stored entry with a data descriptor ends. It looks for the
411
+ // next signature, so a local header inside the data is an entry to it, and part of this entry here.
412
+ for (const e of entries) {
413
+ if (e.method !== 0 || !(e.flags & 0x0008) || e.compressedSize === 0)
414
+ continue;
415
+ const at = await findLocalHeader(reader, e.dataOffset, e.dataOffset + e.compressedSize, deadline);
416
+ if (at !== undefined)
417
+ notes.push({ detail: types_1.OfficeDetail.HiddenEntry, bytes: e.dataOffset + e.compressedSize - at, offset: at });
418
+ }
374
419
  // Entries and the central directory must tile the file. Bytes no entry covers are reported, overlaps refused.
375
420
  const sorted = [...entries].sort((a, b) => a.localOffset - b.localOffset);
421
+ const gaps = [];
376
422
  let covered = 0;
377
423
  let hidden = 0;
378
424
  for (const e of sorted) {
379
425
  if (e.localOffset < covered)
380
426
  fail(types_1.OfficeDetail.Overlap, 'Two entries share bytes', { name: e.name });
427
+ if (e.localOffset > covered)
428
+ gaps.push([covered, e.localOffset]);
381
429
  if (covered === 0 && e.localOffset > 0)
382
430
  notes.push({ detail: types_1.OfficeDetail.LeadingBytes, bytes: e.localOffset });
383
431
  else
384
432
  hidden += e.localOffset - covered;
385
433
  covered = e.end;
386
434
  }
435
+ if (dirOffset > covered)
436
+ gaps.push([covered, dirOffset]);
387
437
  if (sorted.length === 0 && dirOffset > 0)
388
438
  notes.push({ detail: types_1.OfficeDetail.LeadingBytes, bytes: dirOffset });
389
439
  else
390
440
  hidden += dirOffset - covered;
391
441
  if (hidden > 0)
392
442
  notes.push({ detail: types_1.OfficeDetail.HiddenData, bytes: hidden });
443
+ // A local header in a gap is an entry the central directory does not list. A reader that walks the file from the
444
+ // start, as streaming unzippers do, extracts it; a reader of the central directory never sees it.
445
+ for (const [from, to] of gaps) {
446
+ const at = await findLocalHeader(reader, from, to, deadline);
447
+ if (at !== undefined)
448
+ notes.push({ detail: types_1.OfficeDetail.HiddenEntry, bytes: to - from, offset: at });
449
+ }
393
450
  return new ZipArchive(reader, entries, notes, deadline);
394
451
  }
395
452
  /** Checks a local header and any data descriptor against the central directory, and fills in where the entry's data and the entry end. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/office-defuse",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Inspect Office and OpenDocument files for VBA and Excel 4.0 macros, LibreOffice Basic and scripts, DDE, external templates and links, embedded OLE objects and ActiveX controls, and score the risk. Strict ZIP and XML readers, bounded memory, no runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "patrickdk77",
@@ -16,13 +16,16 @@
16
16
  },
17
17
  "./package.json": "./package.json"
18
18
  },
19
+ "bin": {
20
+ "office-defuse": "dist/cli.js"
21
+ },
19
22
  "files": [
20
23
  "dist",
21
24
  "README.md",
22
25
  "LICENSE"
23
26
  ],
24
27
  "engines": {
25
- "node": ">=22.0.0"
28
+ "node": ">=18.0.0"
26
29
  },
27
30
  "scripts": {
28
31
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});require('fs').rmSync('build-test',{recursive:true,force:true})\"",
@@ -84,7 +87,7 @@
84
87
  "@biomejs/biome": "^2.5.15",
85
88
  "@types/chai": "^5.2.3",
86
89
  "@types/mocha": "^10.0.10",
87
- "@types/node": "^22.20.5",
90
+ "@types/node": "^18.19.130",
88
91
  "c8": "^12.0.0",
89
92
  "chai": "^6.3.0",
90
93
  "husky": "^9.1.7",