@patrickdk77/office-defuse 0.2.2 → 0.3.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 +89 -11
- package/dist/biff.d.ts +11 -0
- package/dist/biff.js +15 -0
- package/dist/contained.d.ts +15 -5
- package/dist/contained.js +11 -7
- package/dist/context.d.ts +20 -6
- package/dist/context.js +28 -3
- package/dist/engine.js +7 -6
- package/dist/fields.js +1 -1
- package/dist/findings.js +9 -3
- package/dist/flat.js +4 -7
- package/dist/forms.d.ts +51 -0
- package/dist/forms.js +316 -0
- package/dist/macros.d.ts +26 -0
- package/dist/macros.js +160 -0
- package/dist/msg.js +1 -1
- package/dist/objects.js +76 -17
- package/dist/odf.js +9 -10
- package/dist/ole.d.ts +3 -3
- package/dist/ole.js +55 -5
- package/dist/ooxml.d.ts +26 -0
- package/dist/ooxml.js +163 -15
- 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 +179 -46
- package/dist/rtf.js +4 -2
- package/dist/score.d.ts +2 -0
- package/dist/score.js +12 -0
- 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/package.json +1 -1
package/dist/vba.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { CfbEntry, CompoundFile } from './cfb';
|
|
2
|
+
/**
|
|
3
|
+
* A VBA project as MS-OVBA stores one: a VBA storage with a dir stream that lists the modules, and a stream per module
|
|
4
|
+
* that holds the module's compiled p-code and then its compressed source. Office runs the p-code when the same VBA
|
|
5
|
+
* version compiled it, so the p-code can do something other than the source says (VBA stomping). A project kept after
|
|
6
|
+
* its source was checked is written without the p-code, and with the source compressed again, so Office compiles the
|
|
7
|
+
* text that was checked.
|
|
8
|
+
*/
|
|
9
|
+
export declare class VbaError extends Error {
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Decompresses an MS-OVBA CompressedContainer (2.4.1.3.1). Throws VbaError when a copy token reaches before its chunk,
|
|
13
|
+
* a chunk decompresses past 4096 bytes, or the output passes `limit`. A chunk header without its signature bits is
|
|
14
|
+
* read like any other, and fewer than 3 bytes at the end are taken as padding: Office opens files written that way.
|
|
15
|
+
*/
|
|
16
|
+
export declare function decompress(data: Uint8Array, limit?: number): Uint8Array;
|
|
17
|
+
/**
|
|
18
|
+
* Compresses `data` into an MS-OVBA CompressedContainer (2.4.1.3.6). The longest match comes from a hash chain of
|
|
19
|
+
* the positions in the chunk. Throws VbaError for a last chunk that compresses past 4096 bytes, which MS-OVBA can only
|
|
20
|
+
* write as a raw chunk padded to 4096 bytes, with padding Office would read as source.
|
|
21
|
+
*/
|
|
22
|
+
export declare function compress(data: Uint8Array): Uint8Array;
|
|
23
|
+
/** One record of the dir stream (MS-OVBA 2.3.4.2), as read. */
|
|
24
|
+
interface DirRecord {
|
|
25
|
+
id: number;
|
|
26
|
+
data: Uint8Array;
|
|
27
|
+
}
|
|
28
|
+
/** A module of a VBA project. */
|
|
29
|
+
export interface VbaModule {
|
|
30
|
+
name: string;
|
|
31
|
+
/**
|
|
32
|
+
* What the PROJECT stream says the module is: a document's or a sheet's module, a code module, a class module or a
|
|
33
|
+
* user form. A module the PROJECT stream leaves out is a code or class module as its dir record says.
|
|
34
|
+
*/
|
|
35
|
+
kind: 'document' | 'standard' | 'class' | 'form';
|
|
36
|
+
stream: CfbEntry;
|
|
37
|
+
/** Where the compressed source starts in the stream. The bytes before it are p-code. */
|
|
38
|
+
offset: number;
|
|
39
|
+
/** The source as stored, in the project's code page. */
|
|
40
|
+
bytes: Uint8Array;
|
|
41
|
+
source: string;
|
|
42
|
+
}
|
|
43
|
+
/** A type library or project the VBA project refers to (MS-OVBA 2.3.4.2.2). */
|
|
44
|
+
export interface VbaReference {
|
|
45
|
+
name: string;
|
|
46
|
+
kind: 'registered' | 'control' | 'project';
|
|
47
|
+
/** The library's LibidReference (2.1.1.8), or for a project reference its absolute path (2.1.1.12). */
|
|
48
|
+
libid: string;
|
|
49
|
+
}
|
|
50
|
+
export interface VbaProject {
|
|
51
|
+
storage: CfbEntry;
|
|
52
|
+
dir: CfbEntry;
|
|
53
|
+
codePage: number;
|
|
54
|
+
modules: VbaModule[];
|
|
55
|
+
references: VbaReference[];
|
|
56
|
+
records: DirRecord[];
|
|
57
|
+
/** The MD5 of the references and the modules' names, kinds and sources: what a plugin's verdict is about. */
|
|
58
|
+
digest: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reads the project in `vba`, the VBA storage: the dir stream, the references, and each module's source. Throws
|
|
62
|
+
* VbaError when a stream the dir names is missing or a source does not decompress, which leaves nothing to check.
|
|
63
|
+
*/
|
|
64
|
+
export declare function readProject(cfb: CompoundFile, vba: CfbEntry, limit?: number): Promise<VbaProject>;
|
|
65
|
+
/** The `_VBA_PROJECT` stream of a project no VBA version compiled, which makes Office compile the source. */
|
|
66
|
+
export declare const PURGED_VBA_PROJECT: Uint8Array<ArrayBuffer>;
|
|
67
|
+
/**
|
|
68
|
+
* The project's streams without p-code, as Mandiant's OfficePurge writes them: each module stream holds only its
|
|
69
|
+
* source, compressed again; the dir stream says each source starts at 0; `_VBA_PROJECT` names no VBA version. The
|
|
70
|
+
* `__SRP_` caches and anything else in the VBA storage that the dir does not name go.
|
|
71
|
+
*/
|
|
72
|
+
export declare function purge(project: VbaProject): {
|
|
73
|
+
replace: Map<CfbEntry, Uint8Array>;
|
|
74
|
+
drop: CfbEntry[];
|
|
75
|
+
};
|
|
76
|
+
/** Whether the project is as `purge` writes it, so writing it again changes nothing. */
|
|
77
|
+
export declare function isPurged(cfb: CompoundFile, project: VbaProject): Promise<boolean>;
|
|
78
|
+
export {};
|
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>;
|