@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/README.md +82 -10
- package/dist/biff.d.ts +11 -0
- package/dist/biff.js +15 -0
- package/dist/contained.d.ts +5 -0
- package/dist/contained.js +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.js +10 -0
- package/dist/engine.js +2 -0
- package/dist/fields.js +1 -1
- package/dist/findings.js +7 -1
- package/dist/forms.d.ts +51 -0
- package/dist/forms.js +316 -0
- package/dist/macros.d.ts +21 -0
- package/dist/macros.js +149 -0
- package/dist/objects.js +50 -12
- package/dist/ole.d.ts +3 -3
- package/dist/ole.js +55 -5
- package/dist/ooxml.d.ts +26 -0
- package/dist/ooxml.js +164 -10
- package/dist/ppt-writer.d.ts +27 -0
- package/dist/ppt-writer.js +105 -0
- package/dist/ppt.d.ts +5 -1
- package/dist/ppt.js +177 -46
- package/dist/rewrite-zip.js +2 -1
- package/dist/types.d.ts +44 -0
- package/dist/types.js +3 -0
- package/dist/vba.d.ts +78 -0
- package/dist/vba.js +378 -0
- package/dist/xls-writer.d.ts +34 -0
- package/dist/xls-writer.js +250 -0
- package/dist/xls.d.ts +9 -1
- package/dist/xls.js +126 -43
- package/dist/xml.d.ts +3 -0
- package/dist/xml.js +19 -1
- package/package.json +1 -1
package/dist/vba.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PURGED_VBA_PROJECT = exports.VbaError = void 0;
|
|
4
|
+
exports.decompress = decompress;
|
|
5
|
+
exports.compress = compress;
|
|
6
|
+
exports.readProject = readProject;
|
|
7
|
+
exports.purge = purge;
|
|
8
|
+
exports.isPurged = isPurged;
|
|
9
|
+
const node_crypto_1 = require("node:crypto");
|
|
10
|
+
/**
|
|
11
|
+
* A VBA project as MS-OVBA stores one: a VBA storage with a dir stream that lists the modules, and a stream per module
|
|
12
|
+
* that holds the module's compiled p-code and then its compressed source. Office runs the p-code when the same VBA
|
|
13
|
+
* version compiled it, so the p-code can do something other than the source says (VBA stomping). A project kept after
|
|
14
|
+
* its source was checked is written without the p-code, and with the source compressed again, so Office compiles the
|
|
15
|
+
* text that was checked.
|
|
16
|
+
*/
|
|
17
|
+
class VbaError extends Error {
|
|
18
|
+
}
|
|
19
|
+
exports.VbaError = VbaError;
|
|
20
|
+
const CHUNK = 4096;
|
|
21
|
+
/**
|
|
22
|
+
* Decompresses an MS-OVBA CompressedContainer (2.4.1.3.1). Throws VbaError when a copy token reaches before its chunk,
|
|
23
|
+
* a chunk decompresses past 4096 bytes, or the output passes `limit`. A chunk header without its signature bits is
|
|
24
|
+
* read like any other, and fewer than 3 bytes at the end are taken as padding: Office opens files written that way.
|
|
25
|
+
*/
|
|
26
|
+
function decompress(data, limit = 64 * 1024 * 1024) {
|
|
27
|
+
if (data.length === 0 || data[0] !== 0x01)
|
|
28
|
+
throw new VbaError('a compressed container without its signature byte');
|
|
29
|
+
let out = new Uint8Array(Math.min(limit, Math.max(CHUNK, data.length * 4)));
|
|
30
|
+
let n = 0;
|
|
31
|
+
const room = (k) => {
|
|
32
|
+
if (n + k <= out.length)
|
|
33
|
+
return;
|
|
34
|
+
if (n + k > limit)
|
|
35
|
+
throw new VbaError('decompressed data past the limit');
|
|
36
|
+
const bigger = new Uint8Array(Math.min(limit, Math.max(out.length * 2, n + k)));
|
|
37
|
+
bigger.set(out.subarray(0, n));
|
|
38
|
+
out = bigger;
|
|
39
|
+
};
|
|
40
|
+
let pos = 1;
|
|
41
|
+
while (pos + 3 <= data.length) {
|
|
42
|
+
const header = data[pos] | (data[pos + 1] << 8);
|
|
43
|
+
const end = Math.min(pos + (header & 0x0fff) + 3, data.length);
|
|
44
|
+
const start = n;
|
|
45
|
+
pos += 2;
|
|
46
|
+
if ((header & 0x8000) === 0) {
|
|
47
|
+
if (pos + CHUNK > data.length)
|
|
48
|
+
throw new VbaError('an uncompressed chunk cut short');
|
|
49
|
+
room(CHUNK);
|
|
50
|
+
out.set(data.subarray(pos, pos + CHUNK), n);
|
|
51
|
+
n += CHUNK;
|
|
52
|
+
pos += CHUNK;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
while (pos < end) {
|
|
56
|
+
const flags = data[pos++];
|
|
57
|
+
for (let bit = 0; bit < 8 && pos < end; bit++) {
|
|
58
|
+
if ((flags & (1 << bit)) === 0) {
|
|
59
|
+
room(1);
|
|
60
|
+
out[n++] = data[pos++];
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (pos + 2 > end)
|
|
64
|
+
throw new VbaError('a copy token cut short');
|
|
65
|
+
const token = data[pos] | (data[pos + 1] << 8);
|
|
66
|
+
pos += 2;
|
|
67
|
+
const help = copyTokenHelp(n - start);
|
|
68
|
+
const length = (token & help.lengthMask) + 3;
|
|
69
|
+
const from = n - ((token >> (16 - help.bitCount)) + 1);
|
|
70
|
+
if (from < start)
|
|
71
|
+
throw new VbaError('a copy token that reaches before its chunk');
|
|
72
|
+
room(length);
|
|
73
|
+
// The source and the copy can overlap, so it goes a byte at a time.
|
|
74
|
+
for (let i = 0; i < length; i++)
|
|
75
|
+
out[n + i] = out[from + i];
|
|
76
|
+
n += length;
|
|
77
|
+
}
|
|
78
|
+
if (n - start > CHUNK)
|
|
79
|
+
throw new VbaError('a chunk that decompresses past 4096 bytes');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out.slice(0, n);
|
|
83
|
+
}
|
|
84
|
+
/** MS-OVBA 2.4.1.3.19.1: the masks of a copy token at `difference` bytes into its chunk. */
|
|
85
|
+
function copyTokenHelp(difference) {
|
|
86
|
+
let bitCount = 4;
|
|
87
|
+
while (1 << bitCount < difference)
|
|
88
|
+
bitCount++;
|
|
89
|
+
const lengthMask = 0xffff >> bitCount;
|
|
90
|
+
return { lengthMask, bitCount, maximumLength: lengthMask + 3 };
|
|
91
|
+
}
|
|
92
|
+
/** Candidates a match search looks at for one position. Past it, a shorter match only costs a little space. */
|
|
93
|
+
const MAX_CANDIDATES = 256;
|
|
94
|
+
/**
|
|
95
|
+
* Compresses `data` into an MS-OVBA CompressedContainer (2.4.1.3.6). The longest match comes from a hash chain of
|
|
96
|
+
* the positions in the chunk. Throws VbaError for a last chunk that compresses past 4096 bytes, which MS-OVBA can only
|
|
97
|
+
* write as a raw chunk padded to 4096 bytes, with padding Office would read as source.
|
|
98
|
+
*/
|
|
99
|
+
function compress(data) {
|
|
100
|
+
const out = [0x01];
|
|
101
|
+
for (let chunkStart = 0; chunkStart < data.length; chunkStart += CHUNK) {
|
|
102
|
+
const chunkEnd = Math.min(chunkStart + CHUNK, data.length);
|
|
103
|
+
const body = [];
|
|
104
|
+
const heads = new Map();
|
|
105
|
+
const prev = new Int32Array(chunkEnd - chunkStart).fill(-1);
|
|
106
|
+
const key = (i) => data[i] | (data[i + 1] << 8) | (data[i + 2] << 16);
|
|
107
|
+
let indexed = chunkStart;
|
|
108
|
+
const index = (upTo) => {
|
|
109
|
+
for (; indexed < upTo; indexed++) {
|
|
110
|
+
if (indexed + 3 > chunkEnd)
|
|
111
|
+
continue;
|
|
112
|
+
const k = key(indexed);
|
|
113
|
+
prev[indexed - chunkStart] = heads.get(k) ?? -1;
|
|
114
|
+
heads.set(k, indexed);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
let cur = chunkStart;
|
|
118
|
+
while (cur < chunkEnd) {
|
|
119
|
+
const flagAt = body.length;
|
|
120
|
+
body.push(0);
|
|
121
|
+
let flags = 0;
|
|
122
|
+
for (let bit = 0; bit < 8 && cur < chunkEnd; bit++) {
|
|
123
|
+
index(cur);
|
|
124
|
+
const help = copyTokenHelp(cur - chunkStart);
|
|
125
|
+
let bestLength = 0;
|
|
126
|
+
let bestCandidate = 0;
|
|
127
|
+
if (cur + 3 <= chunkEnd) {
|
|
128
|
+
let candidate = heads.get(key(cur)) ?? -1;
|
|
129
|
+
for (let tries = 0; candidate >= 0 && tries < MAX_CANDIDATES; tries++) {
|
|
130
|
+
let len = 0;
|
|
131
|
+
while (cur + len < chunkEnd && len < help.maximumLength && data[candidate + len] === data[cur + len])
|
|
132
|
+
len++;
|
|
133
|
+
if (len > bestLength) {
|
|
134
|
+
bestLength = len;
|
|
135
|
+
bestCandidate = candidate;
|
|
136
|
+
if (len === help.maximumLength)
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
candidate = prev[candidate - chunkStart];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (bestLength >= 3) {
|
|
143
|
+
const token = (((cur - bestCandidate - 1) << (16 - help.bitCount)) | (bestLength - 3)) & 0xffff;
|
|
144
|
+
body.push(token & 0xff, token >> 8);
|
|
145
|
+
flags |= 1 << bit;
|
|
146
|
+
cur += bestLength;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
body.push(data[cur]);
|
|
150
|
+
cur += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
body[flagAt] = flags;
|
|
154
|
+
}
|
|
155
|
+
if (body.length <= CHUNK) {
|
|
156
|
+
const header = (body.length - 1) | 0xb000;
|
|
157
|
+
out.push(header & 0xff, header >> 8);
|
|
158
|
+
for (const b of body)
|
|
159
|
+
out.push(b);
|
|
160
|
+
}
|
|
161
|
+
else if (chunkEnd - chunkStart === CHUNK) {
|
|
162
|
+
// A raw chunk: the header with the flag clear, then the 4096 bytes as they are.
|
|
163
|
+
const header = 0x3fff;
|
|
164
|
+
out.push(header & 0xff, header >> 8);
|
|
165
|
+
for (let i = chunkStart; i < chunkEnd; i++)
|
|
166
|
+
out.push(data[i]);
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
throw new VbaError('a last chunk that does not compress to 4096 bytes');
|
|
170
|
+
}
|
|
171
|
+
return Uint8Array.from(out);
|
|
172
|
+
}
|
|
173
|
+
const R = {
|
|
174
|
+
codePage: 0x0003,
|
|
175
|
+
version: 0x0009,
|
|
176
|
+
referenceName: 0x0016,
|
|
177
|
+
referenceRegistered: 0x000d,
|
|
178
|
+
referenceProject: 0x000e,
|
|
179
|
+
referenceControl: 0x002f,
|
|
180
|
+
referenceExtended: 0x0030,
|
|
181
|
+
referenceOriginal: 0x0033,
|
|
182
|
+
moduleName: 0x0019,
|
|
183
|
+
moduleStreamName: 0x001a,
|
|
184
|
+
moduleOffset: 0x0031,
|
|
185
|
+
moduleProcedural: 0x0021,
|
|
186
|
+
moduleTerminator: 0x002b,
|
|
187
|
+
dirTerminator: 0x0010,
|
|
188
|
+
};
|
|
189
|
+
function readRecords(dir) {
|
|
190
|
+
const records = [];
|
|
191
|
+
let pos = 0;
|
|
192
|
+
while (pos < dir.length) {
|
|
193
|
+
if (pos + 6 > dir.length)
|
|
194
|
+
throw new VbaError('a dir record cut short');
|
|
195
|
+
const id = dir[pos] | (dir[pos + 1] << 8);
|
|
196
|
+
// PROJECTVERSION says 4 and holds 6: a major version of 4 bytes and a minor one of 2.
|
|
197
|
+
const size = id === R.version ? 6 : u32(dir, pos + 2);
|
|
198
|
+
if (pos + 6 + size > dir.length)
|
|
199
|
+
throw new VbaError('a dir record that runs past the stream');
|
|
200
|
+
records.push({ id, data: dir.subarray(pos + 6, pos + 6 + size) });
|
|
201
|
+
pos += 6 + size;
|
|
202
|
+
if (id === R.dirTerminator)
|
|
203
|
+
return records;
|
|
204
|
+
}
|
|
205
|
+
throw new VbaError('a dir stream without its terminator');
|
|
206
|
+
}
|
|
207
|
+
function writeRecords(records) {
|
|
208
|
+
const parts = [];
|
|
209
|
+
for (const r of records) {
|
|
210
|
+
const size = r.id === R.version ? 4 : r.data.length;
|
|
211
|
+
parts.push(r.id & 0xff, r.id >> 8, size & 0xff, (size >> 8) & 0xff, (size >> 16) & 0xff, (size >>> 24) & 0xff);
|
|
212
|
+
for (const b of r.data)
|
|
213
|
+
parts.push(b);
|
|
214
|
+
}
|
|
215
|
+
return Uint8Array.from(parts);
|
|
216
|
+
}
|
|
217
|
+
const u32 = (b, at = 0) => (b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24)) >>> 0;
|
|
218
|
+
/** The text decoder for a project's code page, with Latin-1 for one Node has no decoder for. */
|
|
219
|
+
function decoderFor(codePage) {
|
|
220
|
+
const labels = { 65001: 'utf-8', 932: 'shift_jis', 936: 'gbk', 949: 'euc-kr', 950: 'big5', 10000: 'macintosh' };
|
|
221
|
+
try {
|
|
222
|
+
return new TextDecoder(labels[codePage] ?? `windows-${codePage}`);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return new TextDecoder('latin1');
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** A string that starts with its 4-byte length, as REFERENCEREGISTERED and the others hold their libids. */
|
|
229
|
+
function sized(b, at, decoder) {
|
|
230
|
+
if (at + 4 > b.length)
|
|
231
|
+
throw new VbaError('a reference record cut short');
|
|
232
|
+
const n = u32(b, at);
|
|
233
|
+
if (at + 4 + n > b.length)
|
|
234
|
+
throw new VbaError('a reference record cut short');
|
|
235
|
+
return { text: decoder.decode(b.subarray(at + 4, at + 4 + n)), next: at + 4 + n };
|
|
236
|
+
}
|
|
237
|
+
function readReferences(records, decoder) {
|
|
238
|
+
const refs = [];
|
|
239
|
+
let name = '';
|
|
240
|
+
let original;
|
|
241
|
+
let control;
|
|
242
|
+
for (const r of records) {
|
|
243
|
+
if (r.id === R.moduleName)
|
|
244
|
+
break;
|
|
245
|
+
if (r.id === R.referenceName) {
|
|
246
|
+
// Inside a REFERENCECONTROL, a name record is the control library's extended name, not a new reference.
|
|
247
|
+
if (!control)
|
|
248
|
+
name = decoder.decode(r.data);
|
|
249
|
+
}
|
|
250
|
+
else if (r.id === R.referenceOriginal)
|
|
251
|
+
original = decoder.decode(r.data);
|
|
252
|
+
else if (r.id === R.referenceRegistered)
|
|
253
|
+
refs.push({ name, kind: 'registered', libid: sized(r.data, 0, decoder).text });
|
|
254
|
+
else if (r.id === R.referenceProject)
|
|
255
|
+
refs.push({ name, kind: 'project', libid: sized(r.data, 0, decoder).text });
|
|
256
|
+
else if (r.id === R.referenceControl) {
|
|
257
|
+
control = { name, kind: 'control', libid: original ?? sized(r.data, 0, decoder).text };
|
|
258
|
+
refs.push(control);
|
|
259
|
+
}
|
|
260
|
+
else if (r.id === R.referenceExtended && control) {
|
|
261
|
+
// The extended libid names the library the control comes from, which the twiddled one only stands in for.
|
|
262
|
+
if (original === undefined)
|
|
263
|
+
control.libid = sized(r.data, 0, decoder).text;
|
|
264
|
+
control = undefined;
|
|
265
|
+
original = undefined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return refs;
|
|
269
|
+
}
|
|
270
|
+
/** The module kinds the PROJECT stream lists (MS-OVBA 2.3.1), by module name in upper case. */
|
|
271
|
+
function projectKinds(text) {
|
|
272
|
+
const kinds = new Map();
|
|
273
|
+
for (const line of text.split(/\r\n|\r|\n/)) {
|
|
274
|
+
if (line.startsWith('['))
|
|
275
|
+
break;
|
|
276
|
+
const m = /^(Document|Module|Class|BaseClass)=([^/]*)/.exec(line);
|
|
277
|
+
if (!m)
|
|
278
|
+
continue;
|
|
279
|
+
const kind = m[1] === 'Document' ? 'document' : m[1] === 'Module' ? 'standard' : m[1] === 'Class' ? 'class' : 'form';
|
|
280
|
+
kinds.set(m[2].toUpperCase(), kind);
|
|
281
|
+
}
|
|
282
|
+
return kinds;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Reads the project in `vba`, the VBA storage: the dir stream, the references, and each module's source. Throws
|
|
286
|
+
* VbaError when a stream the dir names is missing or a source does not decompress, which leaves nothing to check.
|
|
287
|
+
*/
|
|
288
|
+
async function readProject(cfb, vba, limit = 64 * 1024 * 1024) {
|
|
289
|
+
const find = (storage, name) => storage.children.find(c => c.kind === 'stream' && c.name.toUpperCase() === name.toUpperCase());
|
|
290
|
+
const dirEntry = find(vba, 'dir');
|
|
291
|
+
if (!dirEntry)
|
|
292
|
+
throw new VbaError('a VBA storage without its dir stream');
|
|
293
|
+
if (dirEntry.size > limit)
|
|
294
|
+
throw new VbaError('a dir stream past the limit');
|
|
295
|
+
const records = readRecords(decompress(await cfb.bytes(dirEntry), limit));
|
|
296
|
+
const codePageRecord = records.find(r => r.id === R.codePage);
|
|
297
|
+
const codePage = codePageRecord && codePageRecord.data.length >= 2 ? codePageRecord.data[0] | (codePageRecord.data[1] << 8) : 1252;
|
|
298
|
+
const decoder = decoderFor(codePage);
|
|
299
|
+
const projectStream = vba.parent ? find(vba.parent, 'PROJECT') : undefined;
|
|
300
|
+
const kinds = projectStream && projectStream.size <= limit ? projectKinds(decoder.decode(await cfb.bytes(projectStream))) : new Map();
|
|
301
|
+
const modules = [];
|
|
302
|
+
let current;
|
|
303
|
+
let total = 0;
|
|
304
|
+
for (const r of records) {
|
|
305
|
+
if (r.id === R.moduleName)
|
|
306
|
+
current = { name: decoder.decode(r.data), procedural: false };
|
|
307
|
+
else if (!current)
|
|
308
|
+
continue;
|
|
309
|
+
else if (r.id === R.moduleStreamName)
|
|
310
|
+
current.streamName = decoder.decode(r.data);
|
|
311
|
+
else if (r.id === R.moduleOffset && r.data.length >= 4)
|
|
312
|
+
current.offset = u32(r.data);
|
|
313
|
+
else if (r.id === R.moduleProcedural)
|
|
314
|
+
current.procedural = true;
|
|
315
|
+
else if (r.id === R.moduleTerminator) {
|
|
316
|
+
const name = current.name ?? '';
|
|
317
|
+
const streamName = current.streamName ?? name;
|
|
318
|
+
const stream = find(vba, streamName);
|
|
319
|
+
if (!stream)
|
|
320
|
+
throw new VbaError(`a module stream the dir names is missing: ${streamName}`);
|
|
321
|
+
if (stream.size > limit)
|
|
322
|
+
throw new VbaError('a module stream past the limit');
|
|
323
|
+
const offset = current.offset ?? 0;
|
|
324
|
+
const raw = await cfb.bytes(stream);
|
|
325
|
+
if (offset > raw.length)
|
|
326
|
+
throw new VbaError('a module whose source starts past its stream');
|
|
327
|
+
const bytes = decompress(raw.subarray(offset), limit);
|
|
328
|
+
total += bytes.length;
|
|
329
|
+
if (total > limit)
|
|
330
|
+
throw new VbaError('sources past the limit');
|
|
331
|
+
const kind = kinds.get(name.toUpperCase()) ?? (current.procedural ? 'standard' : 'class');
|
|
332
|
+
modules.push({ name, kind, stream, offset, bytes, source: decoder.decode(bytes) });
|
|
333
|
+
current = undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const references = readReferences(records, decoder);
|
|
337
|
+
const hash = (0, node_crypto_1.createHash)('md5');
|
|
338
|
+
hash.update(JSON.stringify(references));
|
|
339
|
+
for (const m of modules)
|
|
340
|
+
hash.update(JSON.stringify([m.name, m.kind, m.stream.name])).update(m.bytes);
|
|
341
|
+
return { storage: vba, dir: dirEntry, codePage, modules, references, records, digest: hash.digest('hex') };
|
|
342
|
+
}
|
|
343
|
+
/** The `_VBA_PROJECT` stream of a project no VBA version compiled, which makes Office compile the source. */
|
|
344
|
+
exports.PURGED_VBA_PROJECT = Uint8Array.of(0xcc, 0x61, 0xff, 0xff, 0x00, 0x00, 0x00);
|
|
345
|
+
/** Streams of the VBA storage that are neither the dir stream, `_VBA_PROJECT`, nor a module the dir names. */
|
|
346
|
+
function otherStreams(project) {
|
|
347
|
+
const known = new Set([project.dir, ...project.modules.map(m => m.stream)]);
|
|
348
|
+
return project.storage.children.filter(c => !known.has(c) && c.name.toUpperCase() !== '_VBA_PROJECT');
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* The project's streams without p-code, as Mandiant's OfficePurge writes them: each module stream holds only its
|
|
352
|
+
* source, compressed again; the dir stream says each source starts at 0; `_VBA_PROJECT` names no VBA version. The
|
|
353
|
+
* `__SRP_` caches and anything else in the VBA storage that the dir does not name go.
|
|
354
|
+
*/
|
|
355
|
+
function purge(project) {
|
|
356
|
+
const replace = new Map();
|
|
357
|
+
for (const m of project.modules)
|
|
358
|
+
replace.set(m.stream, compress(m.bytes));
|
|
359
|
+
const records = project.records.map(r => (r.id === R.moduleOffset ? { id: r.id, data: new Uint8Array(4) } : r));
|
|
360
|
+
replace.set(project.dir, compress(writeRecords(records)));
|
|
361
|
+
const vbaProject = project.storage.children.find(c => c.kind === 'stream' && c.name.toUpperCase() === '_VBA_PROJECT');
|
|
362
|
+
if (vbaProject)
|
|
363
|
+
replace.set(vbaProject, exports.PURGED_VBA_PROJECT);
|
|
364
|
+
return { replace, drop: otherStreams(project) };
|
|
365
|
+
}
|
|
366
|
+
/** Whether the project is as `purge` writes it, so writing it again changes nothing. */
|
|
367
|
+
async function isPurged(cfb, project) {
|
|
368
|
+
if (otherStreams(project).length)
|
|
369
|
+
return false;
|
|
370
|
+
const vbaProject = project.storage.children.find(c => c.kind === 'stream' && c.name.toUpperCase() === '_VBA_PROJECT');
|
|
371
|
+
if (!vbaProject || !same(await cfb.bytes(vbaProject), exports.PURGED_VBA_PROJECT))
|
|
372
|
+
return false;
|
|
373
|
+
for (const m of project.modules)
|
|
374
|
+
if (m.offset !== 0 || !same(await cfb.bytes(m.stream), compress(m.bytes)))
|
|
375
|
+
return false;
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type BiffRecord } from './biff';
|
|
2
|
+
import type { CfbEntry, CompoundFile } from './cfb';
|
|
3
|
+
import type { ByteSink } from './types';
|
|
4
|
+
/** What the rewrite of a BIFF8 workbook stream changes, each by the offset of the record it changes. */
|
|
5
|
+
export interface WorkbookEdits {
|
|
6
|
+
/** BoundSheet8 records whose sheet becomes an empty worksheet of the same name and visibility. */
|
|
7
|
+
emptySheets: Set<number>;
|
|
8
|
+
/** Lbl records renamed to the name given, no longer a built-in name. */
|
|
9
|
+
names: Map<number, string>;
|
|
10
|
+
/** Obj records whose FtMacro, the macro a click runs, goes. */
|
|
11
|
+
objMacros: Set<number>;
|
|
12
|
+
/** SupBook records whose path becomes a reference to the workbook itself. */
|
|
13
|
+
selfBooks: Set<number>;
|
|
14
|
+
/** HLink records that go, with the HLinkTooltip record after one. */
|
|
15
|
+
hyperlinks: Set<number>;
|
|
16
|
+
/** The workbook is rewritten only to decrypt it. Any rewrite of an encrypted workbook decrypts it. */
|
|
17
|
+
decrypt: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare const noEdits: () => WorkbookEdits;
|
|
20
|
+
export declare const hasEdits: (e: WorkbookEdits) => boolean;
|
|
21
|
+
/** A rewrite that cannot keep the stream's offsets right, which leaves the stream as it was. */
|
|
22
|
+
export declare class RewriteError extends Error {
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Writes a workbook stream again into `sink`, record by record in the order the stream holds them, with the edits
|
|
26
|
+
* made. Records change size, so the stream positions other records hold are written anew: BoundSheet8's lbPlyPos,
|
|
27
|
+
* Index's ibXF and rgibRw, DBCell's dbRtrw and ExtSST's ib. A position that does not fall where a record of the stream
|
|
28
|
+
* starts, or inside one that keeps its bytes, throws RewriteError before anything is written. `decrypt` turns each
|
|
29
|
+
* record after FILEPASS into its plain bytes. Bytes after the last whole record are not written.
|
|
30
|
+
*
|
|
31
|
+
* The stream is read twice: once for the record headers and the few bodies the edits change, and once to copy the
|
|
32
|
+
* rest, so the rewrite holds the records' headers, not their bytes.
|
|
33
|
+
*/
|
|
34
|
+
export declare function rewriteWorkbook(cfb: CompoundFile, stream: CfbEntry, edits: WorkbookEdits, biff8: boolean, decrypt: ((r: BiffRecord) => Uint8Array) | undefined, sink: ByteSink, deadline?: number): Promise<void>;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RewriteError = exports.hasEdits = exports.noEdits = void 0;
|
|
4
|
+
exports.rewriteWorkbook = rewriteWorkbook;
|
|
5
|
+
const biff_1 = require("./biff");
|
|
6
|
+
const noEdits = () => ({ emptySheets: new Set(), names: new Map(), objMacros: new Set(), selfBooks: new Set(), hyperlinks: new Set(), decrypt: false });
|
|
7
|
+
exports.noEdits = noEdits;
|
|
8
|
+
const hasEdits = (e) => e.decrypt || e.emptySheets.size > 0 || e.names.size > 0 || e.objMacros.size > 0 || e.selfBooks.size > 0 || e.hyperlinks.size > 0;
|
|
9
|
+
exports.hasEdits = hasEdits;
|
|
10
|
+
/** A rewrite that cannot keep the stream's offsets right, which leaves the stream as it was. */
|
|
11
|
+
class RewriteError extends Error {
|
|
12
|
+
}
|
|
13
|
+
exports.RewriteError = RewriteError;
|
|
14
|
+
const T = { index: 0x020b, dbcell: 0x00d7, extsst: 0x00ff, defColWidth: 0x0055, dimensions: 0x0200, window2: 0x023e, hlinkTooltip: 0x0800 };
|
|
15
|
+
/** Records that hold stream positions: BoundSheet8, Index, DBCell and ExtSST. */
|
|
16
|
+
const POINTERS = new Set([biff_1.R.boundSheet, T.index, T.dbcell, T.extsst]);
|
|
17
|
+
/** BIFF8's largest record body. */
|
|
18
|
+
const MAX_BODY = 8224;
|
|
19
|
+
const u16 = (b, at) => b[at] | (b[at + 1] << 8);
|
|
20
|
+
const u32 = (b, at) => (b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24)) >>> 0;
|
|
21
|
+
const put32 = (b, at, v) => {
|
|
22
|
+
b[at] = v & 0xff;
|
|
23
|
+
b[at + 1] = (v >>> 8) & 0xff;
|
|
24
|
+
b[at + 2] = (v >>> 16) & 0xff;
|
|
25
|
+
b[at + 3] = (v >>> 24) & 0xff;
|
|
26
|
+
};
|
|
27
|
+
const record = (type, body) => ({ type, body, old: -1 });
|
|
28
|
+
/**
|
|
29
|
+
* Writes a workbook stream again into `sink`, record by record in the order the stream holds them, with the edits
|
|
30
|
+
* made. Records change size, so the stream positions other records hold are written anew: BoundSheet8's lbPlyPos,
|
|
31
|
+
* Index's ibXF and rgibRw, DBCell's dbRtrw and ExtSST's ib. A position that does not fall where a record of the stream
|
|
32
|
+
* starts, or inside one that keeps its bytes, throws RewriteError before anything is written. `decrypt` turns each
|
|
33
|
+
* record after FILEPASS into its plain bytes. Bytes after the last whole record are not written.
|
|
34
|
+
*
|
|
35
|
+
* The stream is read twice: once for the record headers and the few bodies the edits change, and once to copy the
|
|
36
|
+
* rest, so the rewrite holds the records' headers, not their bytes.
|
|
37
|
+
*/
|
|
38
|
+
async function rewriteWorkbook(cfb, stream, edits, biff8, decrypt, sink, deadline) {
|
|
39
|
+
const heads = [];
|
|
40
|
+
/** The plain bodies the first pass needs: each BOF, the records that hold stream positions, and those the edits change. */
|
|
41
|
+
const bodies = new Map();
|
|
42
|
+
let filePass = -1;
|
|
43
|
+
const plainOf = (r, i) => Uint8Array.from(filePass >= 0 && i > filePass && decrypt ? decrypt(r) : r.body);
|
|
44
|
+
const reader = new biff_1.RecordReader(cfb, stream, 0, deadline);
|
|
45
|
+
for (let r = await reader.next(); r; r = await reader.next()) {
|
|
46
|
+
const i = heads.length;
|
|
47
|
+
heads.push({ type: r.type, offset: r.offset, length: r.body.length });
|
|
48
|
+
// office-defuse does not encrypt, so an encrypted workbook is written without FILEPASS, every record decrypted.
|
|
49
|
+
if (r.type === biff_1.R.filePass && filePass < 0) {
|
|
50
|
+
if (!decrypt)
|
|
51
|
+
throw new RewriteError('an encrypted workbook without its key');
|
|
52
|
+
filePass = i;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (r.type === biff_1.R.bof || POINTERS.has(r.type) || edits.names.has(r.offset) || edits.objMacros.has(r.offset) || edits.selfBooks.has(r.offset))
|
|
56
|
+
bodies.set(r.offset, plainOf(r, i));
|
|
57
|
+
}
|
|
58
|
+
if (heads[0]?.type !== biff_1.R.bof)
|
|
59
|
+
throw new RewriteError('the stream does not start with a BOF record');
|
|
60
|
+
const indexOf = new Map(heads.map((h, i) => [h.offset, i]));
|
|
61
|
+
// Each substream, from a BOF at the top level to the EOF that closes it, by the offset of its BOF.
|
|
62
|
+
const ends = new Map();
|
|
63
|
+
let depth = 0;
|
|
64
|
+
let open = -1;
|
|
65
|
+
heads.forEach((h, i) => {
|
|
66
|
+
if (h.type === biff_1.R.bof) {
|
|
67
|
+
if (depth === 0)
|
|
68
|
+
open = i;
|
|
69
|
+
depth++;
|
|
70
|
+
}
|
|
71
|
+
else if (h.type === biff_1.R.eof && depth > 0 && --depth === 0)
|
|
72
|
+
ends.set(heads[open].offset, i);
|
|
73
|
+
});
|
|
74
|
+
const empty = new Map();
|
|
75
|
+
for (const at of edits.emptySheets) {
|
|
76
|
+
const body = bodies.get(at);
|
|
77
|
+
if (!body || heads[indexOf.get(at) ?? -1]?.type !== biff_1.R.boundSheet)
|
|
78
|
+
throw new RewriteError('an edit names no BoundSheet record');
|
|
79
|
+
const start = u32(body, 0);
|
|
80
|
+
const end = ends.get(start);
|
|
81
|
+
if (end === undefined)
|
|
82
|
+
throw new RewriteError('a sheet whose position is not where a substream starts');
|
|
83
|
+
empty.set(start, end);
|
|
84
|
+
}
|
|
85
|
+
const out = [];
|
|
86
|
+
for (let i = 0; i < heads.length; i++) {
|
|
87
|
+
const h = heads[i];
|
|
88
|
+
if (i === filePass)
|
|
89
|
+
continue;
|
|
90
|
+
if (edits.hyperlinks.has(h.offset)) {
|
|
91
|
+
if (heads[i + 1]?.type === T.hlinkTooltip)
|
|
92
|
+
i++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const end = empty.get(h.offset);
|
|
96
|
+
if (end !== undefined) {
|
|
97
|
+
emptyWorksheet(bodies.get(h.offset) ?? new Uint8Array(0), biff8).forEach((x, k) => {
|
|
98
|
+
out.push({ type: x.type, source: -1, old: k === 0 ? h.offset : -1, length: x.body.length, body: x.body });
|
|
99
|
+
});
|
|
100
|
+
i = end;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let body = bodies.get(h.offset);
|
|
104
|
+
let changed = false;
|
|
105
|
+
if (body && h.type === biff_1.R.boundSheet && edits.emptySheets.has(h.offset)) {
|
|
106
|
+
body = Uint8Array.from(body);
|
|
107
|
+
body[5] = 0;
|
|
108
|
+
changed = true;
|
|
109
|
+
}
|
|
110
|
+
const name = edits.names.get(h.offset);
|
|
111
|
+
if (body && name !== undefined)
|
|
112
|
+
[body, changed] = [renamed(body, name, biff8), true];
|
|
113
|
+
if (body && edits.objMacros.has(h.offset))
|
|
114
|
+
[body, changed] = [withoutMacro(body), true];
|
|
115
|
+
if (body && edits.selfBooks.has(h.offset))
|
|
116
|
+
[body, changed] = [selfBook(body), true];
|
|
117
|
+
const length = changed && body ? body.length : h.length;
|
|
118
|
+
if (length > MAX_BODY)
|
|
119
|
+
throw new RewriteError('a record past 8,224 bytes');
|
|
120
|
+
out.push({ type: h.type, source: i, old: h.offset, length, body: changed ? body : undefined });
|
|
121
|
+
}
|
|
122
|
+
// New offsets, and the old offset of each record kept as it was, so positions can be followed.
|
|
123
|
+
const moved = new Map();
|
|
124
|
+
const kept = [];
|
|
125
|
+
let pos = 0;
|
|
126
|
+
for (const x of out) {
|
|
127
|
+
if (x.old >= 0) {
|
|
128
|
+
moved.set(x.old, pos);
|
|
129
|
+
if (x.source >= 0 && !x.body)
|
|
130
|
+
kept.push({ old: x.old, now: pos, length: 4 + x.length });
|
|
131
|
+
}
|
|
132
|
+
pos += 4 + x.length;
|
|
133
|
+
}
|
|
134
|
+
const start = (old) => {
|
|
135
|
+
const now = moved.get(old);
|
|
136
|
+
if (now === undefined)
|
|
137
|
+
throw new RewriteError(`a stream position, ${old}, where no record starts`);
|
|
138
|
+
return now;
|
|
139
|
+
};
|
|
140
|
+
// Kept records are in stream order, so the one around a position is found by halving.
|
|
141
|
+
const inside = (old) => {
|
|
142
|
+
let lo = 0;
|
|
143
|
+
let hi = kept.length - 1;
|
|
144
|
+
while (lo < hi) {
|
|
145
|
+
const mid = (lo + hi + 1) >> 1;
|
|
146
|
+
if (kept[mid].old <= old)
|
|
147
|
+
lo = mid;
|
|
148
|
+
else
|
|
149
|
+
hi = mid - 1;
|
|
150
|
+
}
|
|
151
|
+
const k = kept[lo];
|
|
152
|
+
if (!k || old < k.old || old >= k.old + k.length)
|
|
153
|
+
throw new RewriteError(`a stream position, ${old}, inside no record the rewrite keeps as it was`);
|
|
154
|
+
return k.now + (old - k.old);
|
|
155
|
+
};
|
|
156
|
+
const patched = (x, b, at) => {
|
|
157
|
+
if (x.type === biff_1.R.boundSheet && b.length >= 4)
|
|
158
|
+
put32(b, 0, start(u32(b, 0)));
|
|
159
|
+
else if (x.type === T.index && b.length >= (biff8 ? 16 : 12)) {
|
|
160
|
+
// BIFF8 writes rwMic and rwMac in 4 bytes each, BIFF5 in 2.
|
|
161
|
+
const ib = biff8 ? 12 : 8;
|
|
162
|
+
if (u32(b, ib))
|
|
163
|
+
put32(b, ib, start(u32(b, ib)));
|
|
164
|
+
for (let k = ib + 4; k + 4 <= b.length; k += 4)
|
|
165
|
+
put32(b, k, start(u32(b, k)));
|
|
166
|
+
}
|
|
167
|
+
else if (x.type === T.dbcell && b.length >= 4 && x.old >= 0)
|
|
168
|
+
put32(b, 0, at - start(x.old - u32(b, 0)));
|
|
169
|
+
else if (x.type === T.extsst)
|
|
170
|
+
for (let k = 2; k + 8 <= b.length; k += 8)
|
|
171
|
+
put32(b, k, inside(u32(b, k)));
|
|
172
|
+
return b;
|
|
173
|
+
};
|
|
174
|
+
// The records that hold positions are written anew here, so a position that does not resolve fails before the
|
|
175
|
+
// second pass writes anything.
|
|
176
|
+
for (const x of out) {
|
|
177
|
+
if (x.source < 0 || !POINTERS.has(x.type))
|
|
178
|
+
continue;
|
|
179
|
+
const base = x.body ?? bodies.get(x.old);
|
|
180
|
+
if (!base)
|
|
181
|
+
throw new RewriteError('a record that holds stream positions was not read');
|
|
182
|
+
x.body = patched(x, Uint8Array.from(base), moved.get(x.old) ?? 0);
|
|
183
|
+
}
|
|
184
|
+
const again = new biff_1.RecordReader(cfb, stream, 0, deadline);
|
|
185
|
+
let index = -1;
|
|
186
|
+
let current;
|
|
187
|
+
for (const x of out) {
|
|
188
|
+
let b;
|
|
189
|
+
if (x.body)
|
|
190
|
+
b = Uint8Array.from(x.body);
|
|
191
|
+
else {
|
|
192
|
+
while (index < x.source) {
|
|
193
|
+
current = await again.next();
|
|
194
|
+
index++;
|
|
195
|
+
if (!current)
|
|
196
|
+
throw new RewriteError('the stream changed while it was read');
|
|
197
|
+
}
|
|
198
|
+
b = plainOf(current, index);
|
|
199
|
+
}
|
|
200
|
+
await sink.write(Uint8Array.of(x.type & 0xff, x.type >> 8, b.length & 0xff, b.length >> 8));
|
|
201
|
+
await sink.write(b);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The substream of an empty worksheet, from a sheet's BOF: the BOF saying worksheet, a default column width, empty
|
|
206
|
+
* dimensions, a window and EOF.
|
|
207
|
+
*/
|
|
208
|
+
function emptyWorksheet(bof, biff8) {
|
|
209
|
+
const head = Uint8Array.from(bof);
|
|
210
|
+
if (head.length >= 4) {
|
|
211
|
+
head[2] = 0x10;
|
|
212
|
+
head[3] = 0x00;
|
|
213
|
+
}
|
|
214
|
+
// BIFF5's Dimensions and Window2 are shorter: 2-byte rows, and a window without its zoom fields.
|
|
215
|
+
const window = new Uint8Array(biff8 ? 18 : 10);
|
|
216
|
+
window[0] = 0xb6;
|
|
217
|
+
window[6] = 0x40;
|
|
218
|
+
return [record(biff_1.R.bof, head), record(T.defColWidth, Uint8Array.of(8, 0)), record(T.dimensions, new Uint8Array(biff8 ? 14 : 10)), record(T.window2, window), record(biff_1.R.eof, new Uint8Array(0))];
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* An Lbl record (MS-XLS 2.4.150) under another name, which is not a built-in one. BIFF8 writes the name after a flag
|
|
222
|
+
* byte, and BIFF5 writes its characters alone.
|
|
223
|
+
*/
|
|
224
|
+
function renamed(b, name, biff8) {
|
|
225
|
+
const cch = b[3];
|
|
226
|
+
const wide = biff8 && (b[14] & 1) === 1;
|
|
227
|
+
const after = 14 + (biff8 ? 1 : 0) + cch * (wide ? 2 : 1);
|
|
228
|
+
if (after > b.length)
|
|
229
|
+
throw new RewriteError('an Lbl record cut short');
|
|
230
|
+
const head = Uint8Array.from(b.subarray(0, 14));
|
|
231
|
+
head[0] &= ~0x20;
|
|
232
|
+
head[3] = name.length;
|
|
233
|
+
return Uint8Array.from([...head, ...(biff8 ? [0] : []), ...Buffer.from(name, 'latin1'), ...b.subarray(after)]);
|
|
234
|
+
}
|
|
235
|
+
/** An Obj record (MS-XLS 2.4.181) without its FtMacro subrecord. */
|
|
236
|
+
function withoutMacro(b) {
|
|
237
|
+
for (const { ft, at, data } of (0, biff_1.objSubrecords)(b))
|
|
238
|
+
if (ft === 0x04)
|
|
239
|
+
return Buffer.concat([b.subarray(0, at), b.subarray(at + 4 + data.length)]);
|
|
240
|
+
throw new RewriteError('an Obj record without the FtMacro to remove');
|
|
241
|
+
}
|
|
242
|
+
/** A SupBook record (MS-XLS 2.4.271) whose VirtualPath is U+0002, the workbook itself, with its sheet names kept. */
|
|
243
|
+
function selfBook(b) {
|
|
244
|
+
const cch = u16(b, 2);
|
|
245
|
+
const wide = (b[4] & 1) === 1;
|
|
246
|
+
const after = 4 + 1 + cch * (wide ? 2 : 1);
|
|
247
|
+
if (cch === 0 || cch >= 0x0401 || after > b.length)
|
|
248
|
+
throw new RewriteError('a SupBook record without a path');
|
|
249
|
+
return Uint8Array.from([b[0], b[1], 1, 0, 0, 2, ...b.subarray(after)]);
|
|
250
|
+
}
|