@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.
- package/README.md +278 -36
- package/dist/biff.d.ts +62 -0
- package/dist/biff.js +95 -0
- package/dist/cfb-writer.d.ts +25 -0
- package/dist/cfb-writer.js +314 -0
- package/dist/cfb.d.ts +58 -0
- package/dist/cfb.js +456 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +357 -0
- package/dist/contained.d.ts +46 -0
- package/dist/contained.js +152 -0
- package/dist/context.d.ts +140 -17
- package/dist/context.js +135 -37
- package/dist/crc32.d.ts +8 -2
- package/dist/crc32.js +44 -4
- package/dist/engine.d.ts +12 -1
- package/dist/engine.js +336 -42
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +27 -1
- package/dist/fields.d.ts +36 -0
- package/dist/fields.js +139 -0
- package/dist/findings.d.ts +2 -1
- package/dist/findings.js +44 -9
- package/dist/flat.d.ts +33 -0
- package/dist/flat.js +486 -0
- package/dist/formats.d.ts +7 -0
- package/dist/formats.js +5 -1
- package/dist/formulas.d.ts +27 -0
- package/dist/formulas.js +157 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +8 -1
- package/dist/index.mjs +1 -1
- package/dist/io.d.ts +62 -1
- package/dist/io.js +245 -1
- package/dist/links.d.ts +27 -0
- package/dist/links.js +106 -1
- package/dist/msg.d.ts +8 -0
- package/dist/msg.js +251 -0
- package/dist/objects.d.ts +45 -0
- package/dist/objects.js +204 -0
- package/dist/odf.d.ts +66 -2
- package/dist/odf.js +181 -64
- package/dist/offcrypto.d.ts +54 -0
- package/dist/offcrypto.js +647 -0
- package/dist/ole.d.ts +12 -0
- package/dist/ole.js +139 -0
- package/dist/oleobj.d.ts +85 -0
- package/dist/oleobj.js +215 -0
- package/dist/ooxml.d.ts +83 -2
- package/dist/ooxml.js +358 -145
- package/dist/package.d.ts +3 -0
- package/dist/package.js +21 -0
- package/dist/plan.d.ts +104 -0
- package/dist/plan.js +73 -0
- package/dist/plugins.d.ts +8 -0
- package/dist/plugins.js +60 -0
- package/dist/ppt.d.ts +8 -0
- package/dist/ppt.js +281 -0
- package/dist/rewrite-cfb.d.ts +14 -0
- package/dist/rewrite-cfb.js +52 -0
- package/dist/rewrite-rtf.d.ts +9 -0
- package/dist/rewrite-rtf.js +45 -0
- package/dist/rewrite-xml.d.ts +36 -0
- package/dist/rewrite-xml.js +341 -0
- package/dist/rewrite-zip.d.ts +8 -0
- package/dist/rewrite-zip.js +498 -0
- package/dist/rtf.d.ts +8 -0
- package/dist/rtf.js +712 -0
- package/dist/rtfcomp.d.ts +7 -0
- package/dist/rtfcomp.js +80 -0
- package/dist/score.js +1 -1
- package/dist/sniff.d.ts +5 -0
- package/dist/sniff.js +38 -0
- package/dist/types.d.ts +66 -2
- package/dist/types.js +33 -0
- package/dist/vsd.d.ts +13 -0
- package/dist/vsd.js +689 -0
- package/dist/word.d.ts +9 -0
- package/dist/word.js +194 -0
- package/dist/xls.d.ts +27 -0
- package/dist/xls.js +208 -0
- package/dist/xml.d.ts +30 -0
- package/dist/xml.js +45 -23
- package/dist/zip-writer.d.ts +4 -0
- package/dist/zip-writer.js +20 -7
- package/dist/zip.d.ts +9 -2
- package/dist/zip.js +61 -4
- package/package.json +6 -3
package/dist/zip-writer.js
CHANGED
|
@@ -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
|
-
/**
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
/**
|
|
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('
|
|
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.
|
|
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": ">=
|
|
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": "^
|
|
90
|
+
"@types/node": "^18.19.130",
|
|
88
91
|
"c8": "^12.0.0",
|
|
89
92
|
"chai": "^6.3.0",
|
|
90
93
|
"husky": "^9.1.7",
|