@patrickdk77/office-defuse 0.1.1 → 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 +197 -42
- package/dist/cfb-writer.d.ts +25 -0
- package/dist/cfb-writer.js +314 -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 +107 -12
- package/dist/context.js +101 -24
- package/dist/engine.d.ts +12 -1
- package/dist/engine.js +276 -31
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +27 -1
- package/dist/fields.d.ts +20 -13
- package/dist/fields.js +65 -23
- package/dist/findings.d.ts +2 -1
- package/dist/findings.js +17 -1
- 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 +3 -2
- package/dist/msg.js +172 -18
- package/dist/objects.d.ts +8 -5
- package/dist/objects.js +90 -20
- package/dist/odf.d.ts +65 -1
- package/dist/odf.js +181 -64
- package/dist/offcrypto.d.ts +24 -1
- package/dist/offcrypto.js +308 -69
- package/dist/ole.js +17 -5
- package/dist/oleobj.d.ts +11 -0
- package/dist/oleobj.js +20 -3
- package/dist/ooxml.d.ts +81 -0
- package/dist/ooxml.js +342 -81
- 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.js +45 -5
- 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.js +214 -41
- 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 +43 -6
- package/dist/types.js +14 -0
- package/dist/vsd.d.ts +13 -0
- package/dist/vsd.js +689 -0
- package/dist/word.js +51 -17
- package/dist/xls.js +38 -4
- 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 +4 -1
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.writeCfb = writeCfb;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
5
|
+
const MINI_SECTOR = 64;
|
|
6
|
+
const MINI_CUTOFF = 4096;
|
|
7
|
+
const FREE = 0xffffffff;
|
|
8
|
+
const END_OF_CHAIN = 0xfffffffe;
|
|
9
|
+
const FAT_SECTOR = 0xfffffffd;
|
|
10
|
+
const DIFAT_SECTOR = 0xfffffffc;
|
|
11
|
+
const NO_STREAM = 0xffffffff;
|
|
12
|
+
const HEADER_FAT = 109;
|
|
13
|
+
/** A GUID as Windows shows it, back to its 16 bytes. '' gives zeros. */
|
|
14
|
+
function guidBytes(clsid) {
|
|
15
|
+
const out = new Uint8Array(16);
|
|
16
|
+
const h = clsid.replace(/-/g, '');
|
|
17
|
+
if (h.length !== 32)
|
|
18
|
+
return out;
|
|
19
|
+
const byte = (i) => Number.parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
|
20
|
+
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15];
|
|
21
|
+
for (const [i, from] of order.entries())
|
|
22
|
+
out[i] = byte(from);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/** MS-CFB's order of names in a storage: the shorter first, then by their upper-case code units. */
|
|
26
|
+
function compareNames(a, b) {
|
|
27
|
+
if (a.length !== b.length)
|
|
28
|
+
return a.length - b.length;
|
|
29
|
+
const ua = a.toUpperCase();
|
|
30
|
+
const ub = b.toUpperCase();
|
|
31
|
+
return ua < ub ? -1 : ua > ub ? 1 : 0;
|
|
32
|
+
}
|
|
33
|
+
class Bytes {
|
|
34
|
+
b;
|
|
35
|
+
constructor(n) {
|
|
36
|
+
this.b = Buffer.alloc(n);
|
|
37
|
+
}
|
|
38
|
+
u16(at, v) {
|
|
39
|
+
this.b.writeUInt16LE(v, at);
|
|
40
|
+
}
|
|
41
|
+
u32(at, v) {
|
|
42
|
+
this.b.writeUInt32LE(v >>> 0, at);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Writes a compound file (MS-CFB, version 3 with 512-byte sectors) to an append-only sink. The children of each
|
|
47
|
+
* storage go into a balanced tree in MS-CFB's name order, colored as a valid red-black tree. Streams under 4,096 bytes
|
|
48
|
+
* go into the mini stream. The writer holds the allocation tables and the directory, and reads each stream once, in
|
|
49
|
+
* order, from its source.
|
|
50
|
+
*/
|
|
51
|
+
async function writeCfb(tree, sink, deadline) {
|
|
52
|
+
const ss = 512;
|
|
53
|
+
const per = ss / 4;
|
|
54
|
+
// The directory: the root, then every storage's children, depth first.
|
|
55
|
+
const entries = [{ name: 'Root Entry', type: 5, clsid: guidBytes(tree.clsid), left: NO_STREAM, right: NO_STREAM, child: NO_STREAM, red: false, start: END_OF_CHAIN, size: 0 }];
|
|
56
|
+
const place = (children) => {
|
|
57
|
+
const sorted = [...children].sort((a, b) => compareNames(a.name, b.name));
|
|
58
|
+
const ids = [];
|
|
59
|
+
for (const c of sorted) {
|
|
60
|
+
ids.push(entries.length);
|
|
61
|
+
entries.push({
|
|
62
|
+
name: c.name,
|
|
63
|
+
type: c.kind === 'storage' ? 1 : 2,
|
|
64
|
+
clsid: c.kind === 'storage' ? guidBytes(c.clsid) : new Uint8Array(16),
|
|
65
|
+
left: NO_STREAM,
|
|
66
|
+
right: NO_STREAM,
|
|
67
|
+
child: NO_STREAM,
|
|
68
|
+
red: false,
|
|
69
|
+
start: END_OF_CHAIN,
|
|
70
|
+
size: c.kind === 'stream' ? c.size : 0,
|
|
71
|
+
node: c,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
// A median split puts every leaf on the last two levels. The deepest level is red and the rest black, so every
|
|
75
|
+
// path from the root down passes the same number of black entries.
|
|
76
|
+
const height = Math.ceil(Math.log2(sorted.length + 1));
|
|
77
|
+
const build = (lo, hi, depth) => {
|
|
78
|
+
if (lo > hi)
|
|
79
|
+
return NO_STREAM;
|
|
80
|
+
const mid = (lo + hi) >>> 1;
|
|
81
|
+
const e = entries[ids[mid]];
|
|
82
|
+
e.left = build(lo, mid - 1, depth + 1);
|
|
83
|
+
e.right = build(mid + 1, hi, depth + 1);
|
|
84
|
+
e.red = depth === height - 1 && height > 1;
|
|
85
|
+
return ids[mid];
|
|
86
|
+
};
|
|
87
|
+
const root = build(0, sorted.length - 1, 0);
|
|
88
|
+
for (const [i, c] of sorted.entries())
|
|
89
|
+
if (c.kind === 'storage')
|
|
90
|
+
entries[ids[i]].child = place(c.children);
|
|
91
|
+
return root;
|
|
92
|
+
};
|
|
93
|
+
entries[0].child = place(tree.children);
|
|
94
|
+
// Sectors: the FAT and the DIFAT, then the mini stream, the large streams, the directory and the mini FAT, then any
|
|
95
|
+
// free sectors the tail needs. The first sector after the header is always a FAT sector, whose sector numbers can
|
|
96
|
+
// never spell a signature, so no stream lands where PDF readers look for a header.
|
|
97
|
+
// Version 3 keeps sizes in 32 bits.
|
|
98
|
+
if (entries.some(e => e.size > 0xffffffff))
|
|
99
|
+
throw new Error('A stream is too large for a version 3 compound file');
|
|
100
|
+
const small = entries.filter(e => e.type === 2 && e.size > 0 && e.size < MINI_CUTOFF);
|
|
101
|
+
const large = entries.filter(e => e.type === 2 && e.size >= MINI_CUTOFF);
|
|
102
|
+
let miniSectors = 0;
|
|
103
|
+
const miniStart = new Map();
|
|
104
|
+
for (const e of small) {
|
|
105
|
+
miniStart.set(e, miniSectors);
|
|
106
|
+
miniSectors += Math.ceil(e.size / MINI_SECTOR);
|
|
107
|
+
}
|
|
108
|
+
const miniStreamSize = miniSectors * MINI_SECTOR;
|
|
109
|
+
const miniStreamSectors = Math.ceil(miniStreamSize / ss);
|
|
110
|
+
const largeSectors = large.reduce((t, e) => t + Math.ceil(e.size / ss), 0);
|
|
111
|
+
const dirSectors = Math.ceil(entries.length / (ss / 128));
|
|
112
|
+
const miniFatSectors = Math.ceil(miniSectors / per);
|
|
113
|
+
const content = miniStreamSectors + largeSectors + dirSectors + miniFatSectors;
|
|
114
|
+
/** Where everything goes with `pad` free sectors at the end. */
|
|
115
|
+
const layout = (pad) => {
|
|
116
|
+
// The FAT must describe its own sectors and the DIFAT's, so their counts grow together until they fit.
|
|
117
|
+
let fatSectors = 0;
|
|
118
|
+
let difatSectors = 0;
|
|
119
|
+
for (;;) {
|
|
120
|
+
const total = content + pad + fatSectors + difatSectors;
|
|
121
|
+
const needFat = Math.ceil(total / per);
|
|
122
|
+
const needDifat = needFat > HEADER_FAT ? Math.ceil((needFat - HEADER_FAT) / (per - 1)) : 0;
|
|
123
|
+
if (needFat === fatSectors && needDifat === difatSectors)
|
|
124
|
+
break;
|
|
125
|
+
fatSectors = needFat;
|
|
126
|
+
difatSectors = needDifat;
|
|
127
|
+
}
|
|
128
|
+
const base = fatSectors + difatSectors;
|
|
129
|
+
let next = base + miniStreamSectors;
|
|
130
|
+
const starts = new Map();
|
|
131
|
+
for (const e of large) {
|
|
132
|
+
starts.set(e, next);
|
|
133
|
+
next += Math.ceil(e.size / ss);
|
|
134
|
+
}
|
|
135
|
+
const dirStart = next;
|
|
136
|
+
const miniFatStart = miniSectors ? dirStart + dirSectors : END_OF_CHAIN;
|
|
137
|
+
const sectors = base + content + pad;
|
|
138
|
+
return { fatSectors, difatSectors, base, starts, dirStart, miniFatStart, pad, sectors, size: (sectors + 1) * ss };
|
|
139
|
+
};
|
|
140
|
+
const apply = (l) => {
|
|
141
|
+
for (const e of small)
|
|
142
|
+
e.start = miniStart.get(e) ?? 0;
|
|
143
|
+
for (const e of large)
|
|
144
|
+
e.start = l.starts.get(e) ?? 0;
|
|
145
|
+
entries[0].start = miniStreamSectors ? l.base : END_OF_CHAIN;
|
|
146
|
+
entries[0].size = miniStreamSize;
|
|
147
|
+
};
|
|
148
|
+
const fatOf = (l) => {
|
|
149
|
+
const fat = new Uint32Array(l.fatSectors * per).fill(FREE);
|
|
150
|
+
const chain = (start, count) => {
|
|
151
|
+
for (let i = 0; i < count; i++)
|
|
152
|
+
fat[start + i] = i === count - 1 ? END_OF_CHAIN : start + i + 1;
|
|
153
|
+
};
|
|
154
|
+
for (let i = 0; i < l.fatSectors; i++)
|
|
155
|
+
fat[i] = FAT_SECTOR;
|
|
156
|
+
for (let i = 0; i < l.difatSectors; i++)
|
|
157
|
+
fat[l.fatSectors + i] = DIFAT_SECTOR;
|
|
158
|
+
if (miniStreamSectors)
|
|
159
|
+
chain(l.base, miniStreamSectors);
|
|
160
|
+
for (const e of large)
|
|
161
|
+
chain(e.start, Math.ceil(e.size / ss));
|
|
162
|
+
chain(l.dirStart, dirSectors);
|
|
163
|
+
if (miniFatSectors)
|
|
164
|
+
chain(l.miniFatStart, miniFatSectors);
|
|
165
|
+
return fat;
|
|
166
|
+
};
|
|
167
|
+
const directoryOf = () => {
|
|
168
|
+
const dir = new Bytes(dirSectors * ss);
|
|
169
|
+
for (let id = 0; id < dirSectors * (ss / 128); id++) {
|
|
170
|
+
const at = id * 128;
|
|
171
|
+
const e = entries[id];
|
|
172
|
+
if (!e) {
|
|
173
|
+
dir.u32(at + 0x44, NO_STREAM);
|
|
174
|
+
dir.u32(at + 0x48, NO_STREAM);
|
|
175
|
+
dir.u32(at + 0x4c, NO_STREAM);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const name = Buffer.from(e.name, 'utf16le').subarray(0, 62);
|
|
179
|
+
name.copy(dir.b, at);
|
|
180
|
+
dir.u16(at + 0x40, name.length + 2);
|
|
181
|
+
dir.b[at + 0x42] = e.type;
|
|
182
|
+
dir.b[at + 0x43] = e.red ? 0 : 1;
|
|
183
|
+
dir.u32(at + 0x44, e.left);
|
|
184
|
+
dir.u32(at + 0x48, e.right);
|
|
185
|
+
dir.u32(at + 0x4c, e.child);
|
|
186
|
+
Buffer.from(e.clsid).copy(dir.b, at + 0x50);
|
|
187
|
+
dir.u32(at + 0x74, e.type === 1 ? 0 : e.size === 0 && e.type === 2 ? END_OF_CHAIN : e.start);
|
|
188
|
+
dir.u32(at + 0x78, e.size);
|
|
189
|
+
}
|
|
190
|
+
return dir.b;
|
|
191
|
+
};
|
|
192
|
+
const miniFat = new Uint32Array(miniFatSectors * per).fill(FREE);
|
|
193
|
+
for (const e of small) {
|
|
194
|
+
const first = miniStart.get(e) ?? 0;
|
|
195
|
+
const n = Math.ceil(e.size / MINI_SECTOR);
|
|
196
|
+
for (let i = 0; i < n; i++)
|
|
197
|
+
miniFat[first + i] = i === n - 1 ? END_OF_CHAIN : first + i + 1;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The bytes the file holds in [from, to), with streams read from their sources. The header and the DIFAT read as
|
|
201
|
+
* zeros, since what the header holds is fixed and the DIFAT holds only sector numbers.
|
|
202
|
+
*/
|
|
203
|
+
const bytesAt = async (l, from, to) => {
|
|
204
|
+
const out = Buffer.alloc(Math.max(0, to - from));
|
|
205
|
+
const put = (at, b) => {
|
|
206
|
+
const s = Math.max(at, from);
|
|
207
|
+
const e = Math.min(at + b.length, to);
|
|
208
|
+
if (e > s)
|
|
209
|
+
out.set(b.subarray(s - at, e - at), s - from);
|
|
210
|
+
};
|
|
211
|
+
const putStream = async (at, e) => {
|
|
212
|
+
const node = e.node;
|
|
213
|
+
const s = Math.max(at, from);
|
|
214
|
+
const end = Math.min(at + e.size, to);
|
|
215
|
+
if (end <= s || node?.kind !== 'stream')
|
|
216
|
+
return;
|
|
217
|
+
put(s, await node.source.read(s - at, end - s));
|
|
218
|
+
};
|
|
219
|
+
const fat = fatOf(l);
|
|
220
|
+
put(ss, Buffer.from(fat.buffer));
|
|
221
|
+
for (const e of small)
|
|
222
|
+
await putStream((l.base + 1) * ss + (miniStart.get(e) ?? 0) * MINI_SECTOR, e);
|
|
223
|
+
for (const e of large)
|
|
224
|
+
await putStream((e.start + 1) * ss, e);
|
|
225
|
+
put((l.dirStart + 1) * ss, directoryOf());
|
|
226
|
+
if (miniFatSectors)
|
|
227
|
+
put((l.miniFatStart + 1) * ss, Buffer.from(miniFat.buffer));
|
|
228
|
+
return out;
|
|
229
|
+
};
|
|
230
|
+
// ZIP readers look for an end record in the last 65,557 bytes. A stream that ends with one, such as an embedded
|
|
231
|
+
// package, would make the file open as that archive too, so free sectors of zeros push it out of their reach.
|
|
232
|
+
const ZIP_WINDOW = 22 + 0xffff;
|
|
233
|
+
const END_RECORD = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
234
|
+
const lastEndRecord = async (l) => {
|
|
235
|
+
const from = Math.max(ss, l.size - ZIP_WINDOW);
|
|
236
|
+
const found = (await bytesAt(l, from, l.size)).lastIndexOf(END_RECORD);
|
|
237
|
+
return found < 0 ? -1 : from + found;
|
|
238
|
+
};
|
|
239
|
+
let plan = layout(0);
|
|
240
|
+
apply(plan);
|
|
241
|
+
const endAt = await lastEndRecord(plan);
|
|
242
|
+
if (endAt >= 0) {
|
|
243
|
+
plan = layout(Math.ceil((endAt + 1 - (plan.size - ZIP_WINDOW)) / ss));
|
|
244
|
+
apply(plan);
|
|
245
|
+
}
|
|
246
|
+
// Both rules hold by construction. Checking them costs two small reads and turns a mistake into a failed write.
|
|
247
|
+
if ((await lastEndRecord(plan)) >= 0)
|
|
248
|
+
throw new Error('A ZIP end record is still within reach of ZIP readers');
|
|
249
|
+
if ((await bytesAt(plan, 0, Math.min(1024, plan.size))).includes('%PDF-'))
|
|
250
|
+
throw new Error('A PDF header is within the first 1024 bytes');
|
|
251
|
+
const { fatSectors, difatSectors, dirStart, miniFatStart } = plan;
|
|
252
|
+
const fatStart = 0;
|
|
253
|
+
const difatStart = fatSectors;
|
|
254
|
+
const fat = fatOf(plan);
|
|
255
|
+
const header = new Bytes(ss);
|
|
256
|
+
Buffer.from('d0cf11e0a1b11ae1', 'hex').copy(header.b, 0);
|
|
257
|
+
header.u16(0x18, 0x3e);
|
|
258
|
+
header.u16(0x1a, 3);
|
|
259
|
+
header.u16(0x1c, 0xfffe);
|
|
260
|
+
header.u16(0x1e, 9);
|
|
261
|
+
header.u16(0x20, 6);
|
|
262
|
+
header.u32(0x2c, fatSectors);
|
|
263
|
+
header.u32(0x30, dirStart);
|
|
264
|
+
header.u32(0x38, MINI_CUTOFF);
|
|
265
|
+
header.u32(0x3c, miniFatStart);
|
|
266
|
+
header.u32(0x40, miniFatSectors);
|
|
267
|
+
header.u32(0x44, difatSectors ? difatStart : END_OF_CHAIN);
|
|
268
|
+
header.u32(0x48, difatSectors);
|
|
269
|
+
for (let i = 0; i < HEADER_FAT; i++)
|
|
270
|
+
header.u32(0x4c + 4 * i, i < fatSectors ? fatStart + i : FREE);
|
|
271
|
+
await sink.write(header.b);
|
|
272
|
+
await sink.write(Buffer.from(fat.buffer));
|
|
273
|
+
if (difatSectors) {
|
|
274
|
+
const d = new Bytes(difatSectors * ss);
|
|
275
|
+
const rest = Math.max(0, fatSectors - HEADER_FAT);
|
|
276
|
+
for (let k = 0; k < difatSectors; k++) {
|
|
277
|
+
for (let i = 0; i < per - 1; i++) {
|
|
278
|
+
const n = k * (per - 1) + i;
|
|
279
|
+
d.u32(k * ss + 4 * i, n < rest ? fatStart + HEADER_FAT + n : FREE);
|
|
280
|
+
}
|
|
281
|
+
d.u32(k * ss + 4 * (per - 1), k === difatSectors - 1 ? END_OF_CHAIN : difatStart + k + 1);
|
|
282
|
+
}
|
|
283
|
+
await sink.write(d.b);
|
|
284
|
+
}
|
|
285
|
+
/** Copies a stream's bytes and pads them to `unit`. */
|
|
286
|
+
const copy = async (e, unit) => {
|
|
287
|
+
const node = e.node;
|
|
288
|
+
if (node?.kind !== 'stream')
|
|
289
|
+
return;
|
|
290
|
+
for (let at = 0; at < e.size; at += 262144) {
|
|
291
|
+
(0, errors_1.checkTime)(deadline);
|
|
292
|
+
const want = Math.min(262144, e.size - at);
|
|
293
|
+
const b = await node.source.read(at, want);
|
|
294
|
+
if (b.length !== want)
|
|
295
|
+
throw Object.assign(new Error(`A stream returned ${b.length} bytes where ${want} were expected`), { code: 'EIO' });
|
|
296
|
+
await sink.write(b);
|
|
297
|
+
}
|
|
298
|
+
const pad = (unit - (e.size % unit)) % unit;
|
|
299
|
+
if (pad)
|
|
300
|
+
await sink.write(new Uint8Array(pad));
|
|
301
|
+
};
|
|
302
|
+
for (const e of small)
|
|
303
|
+
await copy(e, MINI_SECTOR);
|
|
304
|
+
const miniPad = miniStreamSectors * ss - miniStreamSize;
|
|
305
|
+
if (miniPad)
|
|
306
|
+
await sink.write(new Uint8Array(miniPad));
|
|
307
|
+
for (const e of large)
|
|
308
|
+
await copy(e, ss);
|
|
309
|
+
await sink.write(directoryOf());
|
|
310
|
+
if (miniFatSectors)
|
|
311
|
+
await sink.write(Buffer.from(miniFat.buffer));
|
|
312
|
+
for (let i = 0; i < plan.pad; i++)
|
|
313
|
+
await sink.write(new Uint8Array(ss));
|
|
314
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.main = main;
|
|
38
|
+
const fs = __importStar(require("node:fs"));
|
|
39
|
+
const fsp = __importStar(require("node:fs/promises"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const promises_1 = require("node:stream/promises");
|
|
42
|
+
const node_url_1 = require("node:url");
|
|
43
|
+
const engine_1 = require("./engine");
|
|
44
|
+
const errors_1 = require("./errors");
|
|
45
|
+
const io_1 = require("./io");
|
|
46
|
+
const plugins_1 = require("./plugins");
|
|
47
|
+
const USAGE = `Usage:
|
|
48
|
+
office-defuse inspect <file> [options]
|
|
49
|
+
office-defuse defuse <in> <out> [options]
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--password-file <path> read passwords from a file, one per line; OFFICE_DEFUSE_PASSWORD holds one
|
|
53
|
+
--plugin <module> load file plugins from a file or an installed package, repeatable
|
|
54
|
+
--config <module> load a whole options object, repeatable; the other options apply on top
|
|
55
|
+
--defuse-embedded-office defuse Office files inside the file with office-defuse and keep them
|
|
56
|
+
--limit <name>=<value> fileSize, entries, decompressedBytes, totalDecompressedBytes, xmlDepth,
|
|
57
|
+
xmlTokenSize, xmlEntityDepth, xmlEntityExpansions, xmlEntitySize, rtfDepth,
|
|
58
|
+
nestingDepth, timeMs; sizes accept kb, mb, gb
|
|
59
|
+
--json print the result as JSON
|
|
60
|
+
|
|
61
|
+
Use - for stdin or stdout.
|
|
62
|
+
Exit codes: 0 clean, 1 stripped or strippable, 2 rejected, 3 usage or I/O error.`;
|
|
63
|
+
class UsageError extends Error {
|
|
64
|
+
}
|
|
65
|
+
const SIZES = new Set(['fileSize', 'decompressedBytes', 'totalDecompressedBytes']);
|
|
66
|
+
const COUNTS = new Set(['entries', 'xmlDepth', 'xmlTokenSize', 'xmlEntityDepth', 'xmlEntityExpansions', 'xmlEntitySize', 'rtfDepth', 'nestingDepth', 'timeMs']);
|
|
67
|
+
function parseSize(v) {
|
|
68
|
+
const m = /^(\d+(?:\.\d+)?)\s*(b|kb|k|mb|m|gb|g)?$/i.exec(v.trim());
|
|
69
|
+
if (!m)
|
|
70
|
+
throw new UsageError(`Bad size "${v}"`);
|
|
71
|
+
const mult = { b: 1, k: 1024, kb: 1024, m: 1024 ** 2, mb: 1024 ** 2, g: 1024 ** 3, gb: 1024 ** 3 };
|
|
72
|
+
return Math.round(Number(m[1]) * mult[(m[2] ?? 'b').toLowerCase()]);
|
|
73
|
+
}
|
|
74
|
+
// A real dynamic import. TypeScript would compile import() to require() for CommonJS output,
|
|
75
|
+
// and require() cannot load ES modules or file URLs.
|
|
76
|
+
const dynamicImport = new Function('specifier', 'return import(specifier)');
|
|
77
|
+
/** Reads `key` from `v` the way `v?.[key]` does, for a value whose shape is not known. */
|
|
78
|
+
function prop(v, key) {
|
|
79
|
+
return (typeof v === 'object' && v !== null) || typeof v === 'function' ? v[key] : undefined;
|
|
80
|
+
}
|
|
81
|
+
const isObject = (v) => typeof v === 'object' && v !== null;
|
|
82
|
+
async function loadModule(spec) {
|
|
83
|
+
// A bare name ending in .js can be a file in the working directory or a package with that name. The file wins.
|
|
84
|
+
const looksLikePath = spec.startsWith('.') || path.isAbsolute(spec) || (/\.(c|m)?js$/.test(spec) && fs.statSync(spec, { throwIfNoEntry: false })?.isFile() === true);
|
|
85
|
+
let resolved;
|
|
86
|
+
if (looksLikePath)
|
|
87
|
+
resolved = path.resolve(process.cwd(), spec);
|
|
88
|
+
else {
|
|
89
|
+
try {
|
|
90
|
+
resolved = require.resolve(spec, { paths: [process.cwd()] });
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
// require.resolve applies only the "require" condition, so a package that exports only "import" ends up here.
|
|
94
|
+
if (e.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED')
|
|
95
|
+
throw e;
|
|
96
|
+
const name = spec
|
|
97
|
+
.split('/')
|
|
98
|
+
.slice(0, spec.startsWith('@') ? 2 : 1)
|
|
99
|
+
.join('/');
|
|
100
|
+
const subpath = `.${spec.slice(name.length)}`;
|
|
101
|
+
for (let dir = process.cwd(); resolved === undefined; dir = path.dirname(dir)) {
|
|
102
|
+
const pkg = path.join(dir, 'node_modules', name);
|
|
103
|
+
if (fs.existsSync(path.join(pkg, 'package.json'))) {
|
|
104
|
+
const exp = JSON.parse(fs.readFileSync(path.join(pkg, 'package.json'), 'utf8')).exports;
|
|
105
|
+
const isMap = isObject(exp) && Object.keys(exp).some(k => k.startsWith('.'));
|
|
106
|
+
let target;
|
|
107
|
+
let match;
|
|
108
|
+
if (!isMap)
|
|
109
|
+
target = subpath === '.' ? exp : undefined;
|
|
110
|
+
else if (Object.hasOwn(exp, subpath) && !subpath.includes('*'))
|
|
111
|
+
target = exp[subpath];
|
|
112
|
+
else {
|
|
113
|
+
// A pattern key holds one "*". Node prefers the longest text before the "*", then the longest key, and puts
|
|
114
|
+
// what the "*" matched into the target.
|
|
115
|
+
let best = '';
|
|
116
|
+
for (const k of Object.keys(exp)) {
|
|
117
|
+
const star = k.indexOf('*');
|
|
118
|
+
const trailer = k.slice(star + 1);
|
|
119
|
+
if (star < 0 || trailer.includes('*') || !subpath.startsWith(k.slice(0, star)) || subpath.length === star)
|
|
120
|
+
continue;
|
|
121
|
+
if (trailer && (!subpath.endsWith(trailer) || subpath.length < k.length))
|
|
122
|
+
continue;
|
|
123
|
+
if (!best || star > best.indexOf('*') || (star === best.indexOf('*') && k.length > best.length))
|
|
124
|
+
best = k;
|
|
125
|
+
}
|
|
126
|
+
if (best) {
|
|
127
|
+
target = exp[best];
|
|
128
|
+
match = subpath.slice(best.indexOf('*'), subpath.length - (best.length - best.indexOf('*') - 1));
|
|
129
|
+
// Node refuses a match that would step outside the pattern's directory.
|
|
130
|
+
if (match.split(/[/\\]/).some(s => s === '' || s === '.' || s === '..' || s.toLowerCase() === 'node_modules'))
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Node takes the first condition it supports, in the order the package lists them.
|
|
135
|
+
while (isObject(target) && !Array.isArray(target))
|
|
136
|
+
target = target[Object.keys(target).find(k => k === 'node' || k === 'import' || k === 'default') ?? ''];
|
|
137
|
+
if (typeof target !== 'string')
|
|
138
|
+
throw e;
|
|
139
|
+
resolved = path.resolve(pkg, match === undefined ? target : target.replaceAll('*', match));
|
|
140
|
+
}
|
|
141
|
+
else if (path.dirname(dir) === dir)
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return dynamicImport((0, node_url_1.pathToFileURL)(resolved).href);
|
|
147
|
+
}
|
|
148
|
+
function pluginsFrom(mod) {
|
|
149
|
+
// import() gives a CommonJS module a default export holding module.exports, so the named exports come first.
|
|
150
|
+
const value = prop(mod, 'plugins') ?? prop(prop(mod, 'default'), 'plugins') ?? prop(mod, 'plugin') ?? prop(prop(mod, 'default'), 'plugin') ?? prop(mod, 'default');
|
|
151
|
+
const list = Array.isArray(value) ? value : [value];
|
|
152
|
+
for (const p of list)
|
|
153
|
+
if (!p || prop(p, 'kind') !== 'file')
|
|
154
|
+
throw new UsageError('A plugin module must export plugins with kind "file"');
|
|
155
|
+
// The loop above checked each entry's kind, which is as far as a plugin's shape can be checked here.
|
|
156
|
+
return list;
|
|
157
|
+
}
|
|
158
|
+
async function parse(argv) {
|
|
159
|
+
const [command, ...rest] = argv;
|
|
160
|
+
if (command !== 'inspect' && command !== 'defuse')
|
|
161
|
+
throw new UsageError(command ? `Unknown command "${command}"` : 'No command');
|
|
162
|
+
const positional = [];
|
|
163
|
+
// Every --config module goes in first and the flags apply on top, wherever they sit on the command line.
|
|
164
|
+
const options = {};
|
|
165
|
+
const ordered = [];
|
|
166
|
+
const limits = {};
|
|
167
|
+
let password = process.env.OFFICE_DEFUSE_PASSWORD;
|
|
168
|
+
let json = false;
|
|
169
|
+
for (let i = 0; i < rest.length; i++) {
|
|
170
|
+
const a = rest[i];
|
|
171
|
+
const next = () => {
|
|
172
|
+
const v = rest[++i];
|
|
173
|
+
if (v === undefined)
|
|
174
|
+
throw new UsageError(`${a} needs a value`);
|
|
175
|
+
return v;
|
|
176
|
+
};
|
|
177
|
+
switch (a) {
|
|
178
|
+
case '--password-file':
|
|
179
|
+
// A blank line holds no password, so it adds nothing to the list.
|
|
180
|
+
password = (await fsp.readFile(next(), 'utf8'))
|
|
181
|
+
.split('\n')
|
|
182
|
+
.map(line => line.replace(/\r$/, ''))
|
|
183
|
+
.filter(line => line !== '');
|
|
184
|
+
break;
|
|
185
|
+
case '--plugin':
|
|
186
|
+
ordered.push(...pluginsFrom(await loadModule(next())));
|
|
187
|
+
break;
|
|
188
|
+
case '--config': {
|
|
189
|
+
const mod = await loadModule(next());
|
|
190
|
+
// The module's shape is the caller's to get right, as OfficeOptions documents it.
|
|
191
|
+
const cfg = (prop(mod, 'default') ?? mod);
|
|
192
|
+
// Modules combine: plugin lists join in order and limits merge. For any other option the later module wins.
|
|
193
|
+
Object.assign(options, cfg, {
|
|
194
|
+
filePlugins: [...(options.filePlugins ?? []), ...(cfg.filePlugins ?? [])],
|
|
195
|
+
limits: { ...options.limits, ...cfg.limits },
|
|
196
|
+
});
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case '--defuse-embedded-office':
|
|
200
|
+
ordered.push((0, plugins_1.officePlugin)());
|
|
201
|
+
break;
|
|
202
|
+
case '--limit': {
|
|
203
|
+
const [name, value] = next().split('=');
|
|
204
|
+
if (value === undefined)
|
|
205
|
+
throw new UsageError('--limit expects name=value');
|
|
206
|
+
if (SIZES.has(name))
|
|
207
|
+
limits[name] = parseSize(value);
|
|
208
|
+
else if (COUNTS.has(name)) {
|
|
209
|
+
// Number('abc') is NaN, and no comparison with NaN is true, so the limit would never apply.
|
|
210
|
+
if (!/^\d+$/.test(value.trim()))
|
|
211
|
+
throw new UsageError(`Bad value for ${name}: "${value}"`);
|
|
212
|
+
limits[name] = Number(value);
|
|
213
|
+
}
|
|
214
|
+
else
|
|
215
|
+
throw new UsageError(`Unknown limit "${name}"`);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case '--json':
|
|
219
|
+
json = true;
|
|
220
|
+
break;
|
|
221
|
+
default:
|
|
222
|
+
if (a === '--password' || a.startsWith('--password='))
|
|
223
|
+
throw new UsageError('Pass the password with --password-file or OFFICE_DEFUSE_PASSWORD, not on the command line');
|
|
224
|
+
// What follows "=" can be a secret typed in the wrong place, and stderr often ends up in a log.
|
|
225
|
+
if (a.startsWith('--'))
|
|
226
|
+
throw new UsageError(`Unknown option ${a.replace(/=.*/s, '=...')}`);
|
|
227
|
+
positional.push(a);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (ordered.length)
|
|
231
|
+
options.filePlugins = [...(options.filePlugins ?? []), ...ordered];
|
|
232
|
+
if (Object.keys(limits).length)
|
|
233
|
+
options.limits = { ...(options.limits ?? {}), ...limits };
|
|
234
|
+
if (password !== undefined)
|
|
235
|
+
options.password = password;
|
|
236
|
+
return { command, positional, options, json };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The input as a source. stdin is copied to a temporary file first. The copy stops one byte past `fileSize`, so the
|
|
240
|
+
* engine rejects the upload as too large without the rest being read, and at the deadline, where the source then
|
|
241
|
+
* fails the run for time.
|
|
242
|
+
*/
|
|
243
|
+
async function inputSource(p, tempDir, fileSize, deadline) {
|
|
244
|
+
if (p !== '-') {
|
|
245
|
+
return {
|
|
246
|
+
source: (0, io_1.fileSource)(p),
|
|
247
|
+
cleanup: async () => {
|
|
248
|
+
// The caller's own file stays.
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const temp = new io_1.TempDir(tempDir);
|
|
253
|
+
try {
|
|
254
|
+
const file = await temp.file('.office');
|
|
255
|
+
let room = (fileSize ?? Number.POSITIVE_INFINITY) + 1;
|
|
256
|
+
try {
|
|
257
|
+
await (0, promises_1.pipeline)(process.stdin, async function* (chunks) {
|
|
258
|
+
for await (const c of chunks) {
|
|
259
|
+
yield c.subarray(0, room);
|
|
260
|
+
room -= c.length;
|
|
261
|
+
if (room <= 0)
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}, fs.createWriteStream(file), { signal: deadline === undefined ? undefined : AbortSignal.timeout(Math.max(0, deadline - Date.now())) });
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
if (e.name !== 'AbortError')
|
|
268
|
+
throw e;
|
|
269
|
+
const late = () => Promise.reject(new errors_1.TimeLimitError('Time limit exceeded'));
|
|
270
|
+
return { source: { size: late, read: late }, cleanup: () => temp.cleanup() };
|
|
271
|
+
}
|
|
272
|
+
return { source: (0, io_1.fileSource)(file), cleanup: () => temp.cleanup() };
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
await temp.cleanup();
|
|
276
|
+
throw e;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** Writes a control character as a JSON escape, since text from the file could otherwise drive the terminal. */
|
|
280
|
+
const escaped = (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
281
|
+
function report(label, i) {
|
|
282
|
+
const lines = [`${label}: ${i.status}, risk ${i.risk}${i.score === null ? '' : ` (score ${i.score})`}, ${i.format ? i.format.toUpperCase() : '?'}`];
|
|
283
|
+
for (const f of i.findings)
|
|
284
|
+
lines.push(` ${f.action.padEnd(6)} ${f.category}/${f.detail}${f.location ? ` [${f.location}]` : ''}: ${f.description}`);
|
|
285
|
+
// A location can hold a part name, a field code or a link from the file. A line break in one would print a line of
|
|
286
|
+
// its own.
|
|
287
|
+
return lines.map(l => l.replace(/\p{Cc}/gu, escaped)).join('\n');
|
|
288
|
+
}
|
|
289
|
+
async function main(argv) {
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = await parse(argv);
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
process.stderr.write(`${e.message}\n\n${USAGE}\n`);
|
|
296
|
+
return 3;
|
|
297
|
+
}
|
|
298
|
+
const { command, positional, options, json } = parsed;
|
|
299
|
+
try {
|
|
300
|
+
if (command === 'inspect' && positional.length !== 1)
|
|
301
|
+
throw new UsageError('inspect takes one file');
|
|
302
|
+
if (command === 'defuse' && positional.length !== 2)
|
|
303
|
+
throw new UsageError('defuse takes an input and an output');
|
|
304
|
+
const toStdout = command === 'defuse' && positional[1] === '-';
|
|
305
|
+
if (command === 'defuse' && positional[0] !== '-' && !toStdout) {
|
|
306
|
+
// A failed write deletes the partial output, which here would be the only copy of the input.
|
|
307
|
+
const [a, b] = await Promise.all([fsp.stat(positional[0]), fsp.stat(positional[1]).catch(() => undefined)]);
|
|
308
|
+
if (b && a.dev === b.dev && a.ino === b.ino)
|
|
309
|
+
throw new UsageError('The input and the output are the same file');
|
|
310
|
+
}
|
|
311
|
+
// Reading stdin counts toward the time limit, so the run gets what is left of it.
|
|
312
|
+
const deadline = options.limits?.timeMs === undefined ? undefined : Date.now() + options.limits.timeMs;
|
|
313
|
+
const input = await inputSource(positional[0], options.tempDir, options.limits?.fileSize, deadline);
|
|
314
|
+
const runOptions = deadline === undefined ? options : { ...options, limits: { ...options.limits, timeMs: deadline - Date.now() } };
|
|
315
|
+
let text;
|
|
316
|
+
let code;
|
|
317
|
+
try {
|
|
318
|
+
if (command === 'inspect') {
|
|
319
|
+
const r = await (0, engine_1.inspectOfficeSource)(input.source, runOptions);
|
|
320
|
+
text = json ? JSON.stringify(r, null, 2) : report('inspection', r);
|
|
321
|
+
code = r.status === 'clean' ? 0 : r.status === 'strippable' ? 1 : 2;
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
const r = await (0, engine_1.disarmOfficeSource)(input.source, toStdout ? (0, io_1.writableSink)(process.stdout) : (0, io_1.fileSink)(positional[1]), runOptions);
|
|
325
|
+
text = json ? JSON.stringify(r, null, 2) : [`result: ${r.status}`, report('before', r.before), ...(r.after && r.status === 'defused' ? [report('after', r.after)] : [])].join('\n');
|
|
326
|
+
code = r.status === 'clean' ? 0 : r.status === 'defused' ? 1 : 2;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
await input.source.close?.();
|
|
331
|
+
await input.cleanup();
|
|
332
|
+
}
|
|
333
|
+
// JSON.stringify escapes C0 controls but not DEL or the C1 range.
|
|
334
|
+
if (json)
|
|
335
|
+
text = text.replace(/[\u007f-\u009f]/g, escaped);
|
|
336
|
+
// The report shares stdout only when the file goes elsewhere. A report nobody can read is an I/O error.
|
|
337
|
+
const out = toStdout ? process.stderr : process.stdout;
|
|
338
|
+
await new Promise((resolve, reject) => out.write(`${text}\n`, e => (e ? reject(e) : resolve())));
|
|
339
|
+
return code;
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
process.stderr.write(`${e instanceof UsageError ? `${e.message}\n\n${USAGE}` : `Error: ${e.message}`}\n`);
|
|
343
|
+
return 3;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (require.main === module) {
|
|
347
|
+
// main() learns of a failed write from its callback. Node emits the failure as an 'error' event as well, which
|
|
348
|
+
// with no listener would end the process with exit code 1.
|
|
349
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
350
|
+
stream.on('error', () => {
|
|
351
|
+
process.exitCode = 3;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
main(process.argv.slice(2)).then(code => {
|
|
355
|
+
process.exitCode = code;
|
|
356
|
+
});
|
|
357
|
+
}
|