superdoc-macros 0.7.0 → 0.8.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 +97 -3
- package/dist/import/cfb.d.ts +44 -0
- package/dist/import/cfb.js +378 -0
- package/dist/import/errors.d.ts +21 -0
- package/dist/import/errors.js +20 -0
- package/dist/import/ms-ovba.d.ts +20 -0
- package/dist/import/ms-ovba.js +201 -0
- package/dist/import/vba.d.ts +132 -0
- package/dist/import/vba.js +785 -0
- package/dist/import/zip.d.ts +35 -0
- package/dist/import/zip.js +253 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/manager.d.ts +7 -1
- package/dist/manager.js +21 -6
- package/dist/messages.d.ts +6 -0
- package/dist/messages.js +2 -0
- package/dist/recorder/recorder.d.ts +7 -0
- package/dist/recorder/recorder.js +24 -9
- package/package.json +4 -2
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the VBA macros out of a Word document — for review, never for
|
|
3
|
+
* execution.
|
|
4
|
+
*
|
|
5
|
+
* ## What this is for
|
|
6
|
+
*
|
|
7
|
+
* A user opens a `.docm` they have relied on for years and asks where their
|
|
8
|
+
* macros went. The honest answer used to be "we cannot see them at all".
|
|
9
|
+
* This module changes that: it reads the macro project out of the package and
|
|
10
|
+
* hands back each module's real source text, so a host can show the user what
|
|
11
|
+
* the document actually contains and let them port it to a scripted macro.
|
|
12
|
+
*
|
|
13
|
+
* ## What this is emphatically not
|
|
14
|
+
*
|
|
15
|
+
* There is no VBA engine here and no path to one. Nothing in this module
|
|
16
|
+
* executes, compiles, or converts anything — it decodes bytes into strings.
|
|
17
|
+
* That is a deliberate security position, not a missing feature:
|
|
18
|
+
*
|
|
19
|
+
* - **Nothing runs, ever.** VBA in a document arrives from outside; auto-run
|
|
20
|
+
* entry points (`AutoOpen`, `Document_Open`, …) are a decades-old malware
|
|
21
|
+
* vector precisely because Word honours them. This module reports those
|
|
22
|
+
* procedures in `autoRunProcedures` so a host can warn about them, and
|
|
23
|
+
* gives them no special treatment beyond that.
|
|
24
|
+
* - **Extracted text is data, not code.** A returned `source` string must
|
|
25
|
+
* never be fed to `eval`, to `new Function`, or to the toolkit's script
|
|
26
|
+
* runners: VBA is not JavaScript, so anything that did run would be
|
|
27
|
+
* attacker-chosen text reaching a JavaScript parser. Show it, save it,
|
|
28
|
+
* export it, let a human rewrite it.
|
|
29
|
+
* - **Extraction changes nothing.** Reading a document's macros neither
|
|
30
|
+
* creates a saved macro nor binds a shortcut. Turning any of this into
|
|
31
|
+
* something runnable stays an explicit, human decision.
|
|
32
|
+
*
|
|
33
|
+
* ## Failure posture
|
|
34
|
+
*
|
|
35
|
+
* The public functions never throw — not on a truncated file, not on a
|
|
36
|
+
* hostile one. Every outcome is a discriminated result carrying a stable
|
|
37
|
+
* `reason` code, and partial success is reported honestly: a module that
|
|
38
|
+
* cannot be decoded is listed in `warnings` rather than silently dropped, in
|
|
39
|
+
* the same spirit as the recorder's loss-aware finalization.
|
|
40
|
+
*
|
|
41
|
+
* ## Note on saving
|
|
42
|
+
*
|
|
43
|
+
* Extraction is read-only and does not affect saving. A macro-enabled
|
|
44
|
+
* document that SuperDoc opens and exports keeps its `vbaProject.bin` intact
|
|
45
|
+
* — see the README for the details and for the caveat about the file
|
|
46
|
+
* extension.
|
|
47
|
+
*/
|
|
48
|
+
import { readCfb } from './cfb.js';
|
|
49
|
+
import { isVbaParseError, VbaParseError } from './errors.js';
|
|
50
|
+
import { decompressOvba } from './ms-ovba.js';
|
|
51
|
+
import { openZip } from './zip.js';
|
|
52
|
+
export const VBA_LIMITS = {
|
|
53
|
+
maxModules: 500,
|
|
54
|
+
/** One module's source text, in characters. Longer sources are truncated. */
|
|
55
|
+
maxSourceLength: 500_000,
|
|
56
|
+
/** All modules together, in characters. */
|
|
57
|
+
maxTotalSourceLength: 5_000_000,
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Word runs these without being asked, which is exactly why they are called
|
|
61
|
+
* out. Matching is case-insensitive; VBA identifiers are case-insensitive.
|
|
62
|
+
*/
|
|
63
|
+
const AUTO_RUN_PROCEDURES = [
|
|
64
|
+
'AutoExec',
|
|
65
|
+
'AutoOpen',
|
|
66
|
+
'AutoNew',
|
|
67
|
+
'AutoClose',
|
|
68
|
+
'AutoExit',
|
|
69
|
+
'Document_Open',
|
|
70
|
+
'Document_New',
|
|
71
|
+
'Document_Close',
|
|
72
|
+
'Auto_Open',
|
|
73
|
+
'Auto_Close',
|
|
74
|
+
'Workbook_Open',
|
|
75
|
+
];
|
|
76
|
+
/* ------------------------------------------------------------------ *
|
|
77
|
+
* dir stream
|
|
78
|
+
* ------------------------------------------------------------------ */
|
|
79
|
+
/** [MS-OVBA] 2.3.4.2 record ids this reader cares about. */
|
|
80
|
+
const RECORD = {
|
|
81
|
+
projectCodePage: 0x0003,
|
|
82
|
+
moduleName: 0x0019,
|
|
83
|
+
/** UTF-16 counterpart of MODULENAME — code-page independent. */
|
|
84
|
+
moduleNameUnicode: 0x0047,
|
|
85
|
+
moduleStreamName: 0x001a,
|
|
86
|
+
/** UTF-16 counterpart of MODULESTREAMNAME, stored as a Reserved record. */
|
|
87
|
+
moduleStreamNameUnicode: 0x0032,
|
|
88
|
+
moduleType_procedural: 0x0021,
|
|
89
|
+
moduleType_document: 0x0022,
|
|
90
|
+
moduleOffset: 0x0031,
|
|
91
|
+
/** Declares a 4-byte size but carries 6 — see `recordSize`. */
|
|
92
|
+
projectVersion: 0x0009,
|
|
93
|
+
terminator: 0x0010,
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The declared size of a `dir` record, corrected for the one record whose
|
|
97
|
+
* declared size the format gets wrong. PROJECTVERSION states 4 bytes but is
|
|
98
|
+
* followed by 6 (a 4-byte major and a 2-byte minor); trusting the declared
|
|
99
|
+
* size desynchronizes the whole walk from that point on, and every module
|
|
100
|
+
* after it disappears.
|
|
101
|
+
*/
|
|
102
|
+
function recordSize(id, declared) {
|
|
103
|
+
return id === RECORD.projectVersion ? 6 : declared;
|
|
104
|
+
}
|
|
105
|
+
function parseDirStream(dir) {
|
|
106
|
+
const view = new DataView(dir.buffer, dir.byteOffset, dir.byteLength);
|
|
107
|
+
const modules = [];
|
|
108
|
+
let codePage = null;
|
|
109
|
+
let current = null;
|
|
110
|
+
let complete = false;
|
|
111
|
+
let moduleLimitReached = false;
|
|
112
|
+
let at = 0;
|
|
113
|
+
// Names are decoded once the code page is known, which the format
|
|
114
|
+
// guarantees comes first — but the raw bytes are kept so a project that
|
|
115
|
+
// breaks that ordering still decodes correctly.
|
|
116
|
+
const pendingNames = [];
|
|
117
|
+
while (at + 6 <= dir.length) {
|
|
118
|
+
const id = view.getUint16(at, true);
|
|
119
|
+
const declared = view.getUint32(at + 2, true);
|
|
120
|
+
const size = recordSize(id, declared);
|
|
121
|
+
const dataStart = at + 6;
|
|
122
|
+
if (size < 0 || dataStart + size > dir.length)
|
|
123
|
+
break;
|
|
124
|
+
const data = dir.subarray(dataStart, dataStart + size);
|
|
125
|
+
switch (id) {
|
|
126
|
+
case RECORD.projectCodePage:
|
|
127
|
+
// Unsigned: code page 65001 (UTF-8) would read as a negative number
|
|
128
|
+
// if this were signed.
|
|
129
|
+
if (size >= 2)
|
|
130
|
+
codePage = view.getUint16(dataStart, true);
|
|
131
|
+
break;
|
|
132
|
+
case RECORD.moduleName:
|
|
133
|
+
// Capped here rather than at the consumer, because each entry costs
|
|
134
|
+
// an object and a copied name buffer. A `dir` stream that is nothing
|
|
135
|
+
// but MODULENAME records is a few bytes each and compresses to
|
|
136
|
+
// nothing, so an uncapped walk turns a tiny file into millions of
|
|
137
|
+
// allocations.
|
|
138
|
+
if (modules.length >= VBA_LIMITS.maxModules) {
|
|
139
|
+
moduleLimitReached = true;
|
|
140
|
+
at = dir.length;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
current = {
|
|
144
|
+
name: '',
|
|
145
|
+
streamName: '',
|
|
146
|
+
nameUnicode: null,
|
|
147
|
+
streamNameUnicode: null,
|
|
148
|
+
textOffset: 0,
|
|
149
|
+
hasTextOffset: false,
|
|
150
|
+
kind: 'unknown',
|
|
151
|
+
};
|
|
152
|
+
modules.push(current);
|
|
153
|
+
pendingNames.push({ module: current, field: 'name', bytes: data.slice() });
|
|
154
|
+
break;
|
|
155
|
+
case RECORD.moduleNameUnicode:
|
|
156
|
+
if (current)
|
|
157
|
+
current.nameUnicode = decodeUtf16(data);
|
|
158
|
+
break;
|
|
159
|
+
case RECORD.moduleStreamName:
|
|
160
|
+
if (current)
|
|
161
|
+
pendingNames.push({ module: current, field: 'streamName', bytes: data.slice() });
|
|
162
|
+
break;
|
|
163
|
+
case RECORD.moduleStreamNameUnicode:
|
|
164
|
+
if (current)
|
|
165
|
+
current.streamNameUnicode = decodeUtf16(data);
|
|
166
|
+
break;
|
|
167
|
+
case RECORD.moduleOffset:
|
|
168
|
+
if (current && size >= 4) {
|
|
169
|
+
current.textOffset = view.getUint32(dataStart, true);
|
|
170
|
+
current.hasTextOffset = true;
|
|
171
|
+
}
|
|
172
|
+
break;
|
|
173
|
+
case RECORD.moduleType_procedural:
|
|
174
|
+
if (current)
|
|
175
|
+
current.kind = 'standard';
|
|
176
|
+
break;
|
|
177
|
+
case RECORD.moduleType_document:
|
|
178
|
+
// [MS-OVBA]: this id means "document, class, or designer module" — it
|
|
179
|
+
// does not distinguish them. Claiming `class` here would be a guess;
|
|
180
|
+
// the PROJECT stream is what actually tells them apart, and when it
|
|
181
|
+
// is unreadable `unknown` is the honest answer.
|
|
182
|
+
if (current)
|
|
183
|
+
current.kind = 'unknown';
|
|
184
|
+
break;
|
|
185
|
+
case RECORD.terminator:
|
|
186
|
+
complete = true;
|
|
187
|
+
at = dir.length;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
at = dataStart + size;
|
|
191
|
+
}
|
|
192
|
+
return { modules, codePage, pendingNames, complete, moduleLimitReached };
|
|
193
|
+
}
|
|
194
|
+
/** UTF-16LE, the encoding the format's `*Unicode` records and CFB names use. */
|
|
195
|
+
function decodeUtf16(bytes) {
|
|
196
|
+
if (bytes.length === 0)
|
|
197
|
+
return null;
|
|
198
|
+
try {
|
|
199
|
+
const text = new TextDecoder('utf-16le').decode(bytes);
|
|
200
|
+
return text.length > 0 ? text : null;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/* ------------------------------------------------------------------ *
|
|
207
|
+
* text decoding
|
|
208
|
+
* ------------------------------------------------------------------ */
|
|
209
|
+
/**
|
|
210
|
+
* VBA stores names and sources in the project's code page, not UTF-8 — a
|
|
211
|
+
* Hebrew comment written in Word lands as windows-1255 bytes. Decoding it as
|
|
212
|
+
* UTF-8 turns the whole file into replacement characters, so the declared
|
|
213
|
+
* code page has to be honoured.
|
|
214
|
+
*/
|
|
215
|
+
const CODE_PAGE_LABELS = {
|
|
216
|
+
874: 'windows-874',
|
|
217
|
+
932: 'shift_jis',
|
|
218
|
+
936: 'gbk',
|
|
219
|
+
949: 'euc-kr',
|
|
220
|
+
950: 'big5',
|
|
221
|
+
1250: 'windows-1250',
|
|
222
|
+
1251: 'windows-1251',
|
|
223
|
+
1252: 'windows-1252',
|
|
224
|
+
1253: 'windows-1253',
|
|
225
|
+
1254: 'windows-1254',
|
|
226
|
+
1255: 'windows-1255',
|
|
227
|
+
1256: 'windows-1256',
|
|
228
|
+
1257: 'windows-1257',
|
|
229
|
+
1258: 'windows-1258',
|
|
230
|
+
10000: 'macintosh',
|
|
231
|
+
10007: 'x-mac-cyrillic',
|
|
232
|
+
20127: 'windows-1252', // US-ASCII, a strict subset
|
|
233
|
+
20866: 'koi8-r',
|
|
234
|
+
21866: 'koi8-u',
|
|
235
|
+
28591: 'iso-8859-1',
|
|
236
|
+
28592: 'iso-8859-2',
|
|
237
|
+
28593: 'iso-8859-3',
|
|
238
|
+
28594: 'iso-8859-4',
|
|
239
|
+
28595: 'iso-8859-5',
|
|
240
|
+
28596: 'iso-8859-6',
|
|
241
|
+
28597: 'iso-8859-7',
|
|
242
|
+
28598: 'iso-8859-8',
|
|
243
|
+
28599: 'iso-8859-9',
|
|
244
|
+
28603: 'iso-8859-13',
|
|
245
|
+
28605: 'iso-8859-15',
|
|
246
|
+
65001: 'utf-8',
|
|
247
|
+
};
|
|
248
|
+
const FALLBACK_CODE_PAGE = 1252;
|
|
249
|
+
function makeDecoder(codePage) {
|
|
250
|
+
const label = CODE_PAGE_LABELS[codePage];
|
|
251
|
+
if (label) {
|
|
252
|
+
try {
|
|
253
|
+
const decoder = new TextDecoder(label);
|
|
254
|
+
return { decode: (bytes) => decoder.decode(bytes), recognized: true };
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// The environment does not carry this encoding — fall through.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const fallback = new TextDecoder(CODE_PAGE_LABELS[FALLBACK_CODE_PAGE] ?? 'windows-1252');
|
|
261
|
+
return { decode: (bytes) => fallback.decode(bytes), recognized: false };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The `PROJECT` stream is plain text and states each module's kind, which the
|
|
265
|
+
* `dir` stream only narrows to "procedural or not". It is the only way to
|
|
266
|
+
* tell a UserForm's code-behind from an ordinary class — and, because it
|
|
267
|
+
* names every module independently, it doubles as a check on the directory
|
|
268
|
+
* walk.
|
|
269
|
+
*/
|
|
270
|
+
function parseProjectStream(text) {
|
|
271
|
+
const declared = new Map();
|
|
272
|
+
const maxDeclared = VBA_LIMITS.maxModules * 2;
|
|
273
|
+
const keyToKind = {
|
|
274
|
+
module: 'standard',
|
|
275
|
+
class: 'class',
|
|
276
|
+
document: 'document',
|
|
277
|
+
baseclass: 'form',
|
|
278
|
+
};
|
|
279
|
+
for (const line of text.split(/\r\n|\r|\n/)) {
|
|
280
|
+
if (declared.size >= maxDeclared)
|
|
281
|
+
break;
|
|
282
|
+
const match = /^(Module|Class|Document|BaseClass)=(.*)$/i.exec(line.trim());
|
|
283
|
+
if (!match)
|
|
284
|
+
continue;
|
|
285
|
+
const kind = keyToKind[match[1].toLowerCase()];
|
|
286
|
+
// `Document=ThisDocument/&H00000000` — the name stops at the slash.
|
|
287
|
+
const name = (match[2] ?? '').split('/')[0]?.trim();
|
|
288
|
+
// Keyed case-insensitively, as VBA identifiers are, but the name is kept
|
|
289
|
+
// as written so a warning can quote it the way the user would see it.
|
|
290
|
+
if (kind && name)
|
|
291
|
+
declared.set(name.toLowerCase(), { kind, name });
|
|
292
|
+
}
|
|
293
|
+
return declared;
|
|
294
|
+
}
|
|
295
|
+
/* ------------------------------------------------------------------ *
|
|
296
|
+
* auto-run detection
|
|
297
|
+
* ------------------------------------------------------------------ */
|
|
298
|
+
const PROCEDURE_PATTERN = /^[ \t]*(?:(?:Public|Private|Friend)[ \t]+)?(?:Static[ \t]+)?(?:Sub|Function)[ \t]+([A-Za-z_][A-Za-z0-9_]*)/gim;
|
|
299
|
+
/**
|
|
300
|
+
* The auto-run entry points declared in a VBA source text, in the order they
|
|
301
|
+
* appear, spelled as the source spells them.
|
|
302
|
+
*
|
|
303
|
+
* Exported because the warning matters more than where the text came from: a
|
|
304
|
+
* host showing VBA a user pasted in, or read from a `.bas` file, wants the
|
|
305
|
+
* same "Word would have run this on open" notice this module attaches to an
|
|
306
|
+
* extracted project.
|
|
307
|
+
*
|
|
308
|
+
* Names are matched case-insensitively, since VBA identifiers are.
|
|
309
|
+
*/
|
|
310
|
+
export function scanForAutoRunProcedures(source) {
|
|
311
|
+
const autoRun = new Set(AUTO_RUN_PROCEDURES.map((name) => name.toLowerCase()));
|
|
312
|
+
// A fresh regex per call: a /g pattern carries lastIndex between uses, so a
|
|
313
|
+
// shared instance would skip matches on the second call.
|
|
314
|
+
const pattern = new RegExp(PROCEDURE_PATTERN.source, PROCEDURE_PATTERN.flags);
|
|
315
|
+
const found = [];
|
|
316
|
+
let match;
|
|
317
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
318
|
+
const procedure = match[1];
|
|
319
|
+
if (procedure && autoRun.has(procedure.toLowerCase()))
|
|
320
|
+
found.push(procedure);
|
|
321
|
+
}
|
|
322
|
+
return found;
|
|
323
|
+
}
|
|
324
|
+
/* ------------------------------------------------------------------ *
|
|
325
|
+
* vbaProject.bin
|
|
326
|
+
* ------------------------------------------------------------------ */
|
|
327
|
+
/**
|
|
328
|
+
* Reads a VBA project from the bytes of a `vbaProject.bin` part.
|
|
329
|
+
*
|
|
330
|
+
* Never throws. A partially readable project comes back as `ok: true` with
|
|
331
|
+
* the modules that could be read and a warning for each that could not.
|
|
332
|
+
*
|
|
333
|
+
* @param bin The macro part's bytes.
|
|
334
|
+
* @param partName Where the part came from, recorded on the result. Passed by
|
|
335
|
+
* `extractVbaFromDocx`; a caller reading a loose `.bin` has nothing to give.
|
|
336
|
+
*/
|
|
337
|
+
export function extractVbaProject(bin, partName) {
|
|
338
|
+
try {
|
|
339
|
+
return readProject(bin, partName);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
return toFailure(error, 'not-a-vba-project', partName);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function readProject(bin, partName) {
|
|
346
|
+
const container = readCfb(bin);
|
|
347
|
+
const warnings = [];
|
|
348
|
+
const dirPath = findDirStream(container);
|
|
349
|
+
if (!dirPath) {
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
reason: 'not-a-vba-project',
|
|
353
|
+
message: 'The macro project has no VBA directory stream.',
|
|
354
|
+
...(partName === undefined ? {} : { partName }),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const storagePath = dirPath.slice(0, dirPath.lastIndexOf('/'));
|
|
358
|
+
const dir = decompressOvba(container.readStream(dirPath));
|
|
359
|
+
const parsed = parseDirStream(dir);
|
|
360
|
+
if (parsed.moduleLimitReached) {
|
|
361
|
+
warnings.push({
|
|
362
|
+
code: 'module-limit',
|
|
363
|
+
message: `The project declares more than ${VBA_LIMITS.maxModules} modules; the rest were not read.`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
else if (!parsed.complete) {
|
|
367
|
+
warnings.push({
|
|
368
|
+
code: 'incomplete-directory',
|
|
369
|
+
message: 'The macro directory ended unexpectedly; some modules may be missing from this list.',
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const codePage = parsed.codePage ?? FALLBACK_CODE_PAGE;
|
|
373
|
+
const decoder = makeDecoder(codePage);
|
|
374
|
+
if (!decoder.recognized) {
|
|
375
|
+
warnings.push({
|
|
376
|
+
code: 'unknown-code-page',
|
|
377
|
+
message: `Code page ${codePage} is not available; text was decoded as windows-${FALLBACK_CODE_PAGE} and non-Latin characters may be wrong.`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
for (const pending of parsed.pendingNames) {
|
|
381
|
+
pending.module[pending.field] = decoder.decode(pending.bytes);
|
|
382
|
+
}
|
|
383
|
+
const declaredModules = readDeclaredModules(container, storagePath, decoder.decode);
|
|
384
|
+
const modules = [];
|
|
385
|
+
const autoRunProcedures = [];
|
|
386
|
+
let totalSource = 0;
|
|
387
|
+
let considered = 0;
|
|
388
|
+
for (const entry of parsed.modules) {
|
|
389
|
+
const name = entry.nameUnicode ?? entry.name;
|
|
390
|
+
if (!name)
|
|
391
|
+
continue;
|
|
392
|
+
// Counted per module *considered*, not per module successfully read.
|
|
393
|
+
// Keying the cap on successes lets a project whose every stream fails
|
|
394
|
+
// run the loop over every record it declares and push a warning each
|
|
395
|
+
// time — an unbounded warning list from a tiny file.
|
|
396
|
+
considered += 1;
|
|
397
|
+
if (considered > VBA_LIMITS.maxModules) {
|
|
398
|
+
warnings.push({
|
|
399
|
+
code: 'module-limit',
|
|
400
|
+
message: `The project declares more than ${VBA_LIMITS.maxModules} modules; the rest were not read.`,
|
|
401
|
+
});
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
if (totalSource >= VBA_LIMITS.maxTotalSourceLength) {
|
|
405
|
+
warnings.push({
|
|
406
|
+
code: 'total-size-limit',
|
|
407
|
+
message: 'The project’s total source size exceeds the cap; the remaining modules were not read.',
|
|
408
|
+
});
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
let decoded;
|
|
412
|
+
try {
|
|
413
|
+
const stream = readModuleStream(container, storagePath, entry, name);
|
|
414
|
+
decoded = decoder.decode(decompressOvba(stream, { offset: entry.textOffset }));
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// Honest partial result: the module is named in a warning rather than
|
|
418
|
+
// vanishing from the list without explanation. The stored source being
|
|
419
|
+
// absent is a real possibility here, not only damage — some documents
|
|
420
|
+
// ship with it stripped and only the compiled form left behind.
|
|
421
|
+
warnings.push({
|
|
422
|
+
code: 'module-unreadable',
|
|
423
|
+
message: `Module “${name}” could not be decoded and was skipped — its stored source may be absent or damaged.`,
|
|
424
|
+
});
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const truncated = decoded.length > VBA_LIMITS.maxSourceLength;
|
|
428
|
+
const source = truncated ? decoded.slice(0, VBA_LIMITS.maxSourceLength) : decoded;
|
|
429
|
+
if (truncated) {
|
|
430
|
+
warnings.push({
|
|
431
|
+
code: 'module-truncated',
|
|
432
|
+
message: `Module “${name}” is longer than ${VBA_LIMITS.maxSourceLength} characters and was truncated.`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
modules.push({
|
|
436
|
+
name,
|
|
437
|
+
kind: declaredModules.get(name.toLowerCase())?.kind ?? entry.kind,
|
|
438
|
+
source,
|
|
439
|
+
truncated,
|
|
440
|
+
});
|
|
441
|
+
totalSource += source.length;
|
|
442
|
+
for (const procedure of scanForAutoRunProcedures(source)) {
|
|
443
|
+
autoRunProcedures.push({ module: name, procedure });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// Cross-check against the PROJECT stream, which names every module
|
|
447
|
+
// independently of the directory walk. If the two disagree, a record in the
|
|
448
|
+
// directory misstated its length and desynchronized the walk — and the user
|
|
449
|
+
// is looking at an incomplete list. Saying which modules are missing is the
|
|
450
|
+
// difference between a known gap and a silent one.
|
|
451
|
+
if (declaredModules.size > 0) {
|
|
452
|
+
const read = new Set(modules.map((module) => module.name.toLowerCase()));
|
|
453
|
+
const missing = [...declaredModules.entries()]
|
|
454
|
+
.filter(([key]) => !read.has(key))
|
|
455
|
+
.map(([, declared]) => declared.name);
|
|
456
|
+
if (missing.length > 0) {
|
|
457
|
+
// The list is for a person to read, so it is trimmed rather than
|
|
458
|
+
// allowed to become a multi-megabyte string.
|
|
459
|
+
const shown = missing.slice(0, 20);
|
|
460
|
+
const rest = missing.length - shown.length;
|
|
461
|
+
warnings.push({
|
|
462
|
+
code: 'declared-modules-missing',
|
|
463
|
+
message: `The project declares ${missing.length} module(s) that could not be read: ${shown.join(', ')}${rest > 0 ? `, and ${rest} more` : ''}.`,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (modules.length === 0) {
|
|
468
|
+
warnings.push({ code: 'no-modules', message: 'The macro project contains no readable modules.' });
|
|
469
|
+
}
|
|
470
|
+
if (autoRunProcedures.length > 0) {
|
|
471
|
+
const list = autoRunProcedures.map((ref) => `${ref.module}.${ref.procedure}`).join(', ');
|
|
472
|
+
warnings.push({
|
|
473
|
+
code: 'auto-run-macros',
|
|
474
|
+
message: `This document defines macros Word would run automatically (${list}). They were read for review only and are not executed.`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
ok: true,
|
|
479
|
+
project: {
|
|
480
|
+
modules,
|
|
481
|
+
codePage,
|
|
482
|
+
autoRunProcedures,
|
|
483
|
+
warnings,
|
|
484
|
+
...(partName === undefined ? {} : { partName }),
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/** How much of the `PROJECT` stream is worth parsing. Real ones are a few hundred bytes. */
|
|
489
|
+
const MAX_PROJECT_STREAM_BYTES = 256 * 1024;
|
|
490
|
+
/**
|
|
491
|
+
* The modules the `PROJECT` stream declares, with their kinds.
|
|
492
|
+
*
|
|
493
|
+
* `PROJECT` sits beside the VBA storage rather than at a fixed path, so it is
|
|
494
|
+
* looked up relative to where the directory stream was actually found. An
|
|
495
|
+
* unreadable or absent stream is not an error: kinds degrade to what `dir`
|
|
496
|
+
* said, and the cross-check simply has nothing to compare against.
|
|
497
|
+
*/
|
|
498
|
+
function readDeclaredModules(container, storagePath, decode) {
|
|
499
|
+
const parent = storagePath.slice(0, storagePath.lastIndexOf('/'));
|
|
500
|
+
const candidates = [`${parent}/PROJECT`, '/PROJECT'];
|
|
501
|
+
for (const path of candidates) {
|
|
502
|
+
const entry = container.entries.find((candidate) => candidate.type === 'stream' && candidate.path === path);
|
|
503
|
+
// Checked before reading, so an absurdly sized stream is never
|
|
504
|
+
// materialized just to be thrown away.
|
|
505
|
+
if (!entry || entry.size === 0 || entry.size > MAX_PROJECT_STREAM_BYTES)
|
|
506
|
+
continue;
|
|
507
|
+
try {
|
|
508
|
+
return parseProjectStream(decode(container.readStream(path)));
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// Try the next candidate path.
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return new Map();
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The bytes of one module's stream.
|
|
518
|
+
*
|
|
519
|
+
* Tries the UTF-16 stream name first, then the code-page one, then a
|
|
520
|
+
* case-insensitive match. The order matters for non-Latin names: CFB stores
|
|
521
|
+
* directory names as UTF-16, so the UTF-16 record matches them regardless of
|
|
522
|
+
* which code pages the host's `TextDecoder` happens to support, while the
|
|
523
|
+
* code-page name can decode to something that matches nothing.
|
|
524
|
+
*/
|
|
525
|
+
function readModuleStream(container, storagePath, entry, fallbackName) {
|
|
526
|
+
const names = [entry.streamNameUnicode, entry.streamName, entry.nameUnicode, fallbackName];
|
|
527
|
+
const tried = new Set();
|
|
528
|
+
for (const name of names) {
|
|
529
|
+
if (!name || tried.has(name))
|
|
530
|
+
continue;
|
|
531
|
+
tried.add(name);
|
|
532
|
+
try {
|
|
533
|
+
return container.readStream(`${storagePath}/${name}`);
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// Try the next spelling.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// MS-CFB compares entry names case-insensitively, so a project whose
|
|
540
|
+
// recorded stream name differs only in case is still valid.
|
|
541
|
+
const wanted = [...tried].map((name) => `${storagePath}/${name}`.toLowerCase());
|
|
542
|
+
const match = container.entries.find((candidate) => candidate.type === 'stream' && wanted.includes(candidate.path.toLowerCase()));
|
|
543
|
+
if (match)
|
|
544
|
+
return container.readStream(match.path);
|
|
545
|
+
throw new VbaParseError('malformed', `OLE container: no stream for module ${fallbackName}`);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The `dir` stream, by path. Word and Excel keep it under a `VBA` storage;
|
|
549
|
+
* other producers use a different storage name, so a `VBA` parent is
|
|
550
|
+
* preferred but not required.
|
|
551
|
+
*/
|
|
552
|
+
function findDirStream(container) {
|
|
553
|
+
const candidates = container.entries.filter((entry) => entry.type === 'stream' && entry.name.toLowerCase() === 'dir');
|
|
554
|
+
const preferred = candidates.find((entry) => /\/vba\/dir$/i.test(entry.path));
|
|
555
|
+
return preferred?.path ?? candidates[0]?.path ?? null;
|
|
556
|
+
}
|
|
557
|
+
/* ------------------------------------------------------------------ *
|
|
558
|
+
* .docm / .dotm packages
|
|
559
|
+
* ------------------------------------------------------------------ */
|
|
560
|
+
const VBA_RELATIONSHIP_TYPE = 'http://schemas.microsoft.com/office/2006/relationships/vbaProject';
|
|
561
|
+
const OFFICE_DOCUMENT_RELATIONSHIP_TYPE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument';
|
|
562
|
+
/**
|
|
563
|
+
* Whether a Word package carries a macro project, and where.
|
|
564
|
+
*
|
|
565
|
+
* Returns the part's path inside the package, or `null`. Only the
|
|
566
|
+
* relationship and content-type parts are read — nothing is decompressed from
|
|
567
|
+
* the macro project itself — so this is the cheap question to ask when the
|
|
568
|
+
* answer decides something other than what to display.
|
|
569
|
+
*
|
|
570
|
+
* The reason it exists separately: a document carrying a macro project must be
|
|
571
|
+
* saved as `.docm`, and that decision has to hold even for a project too
|
|
572
|
+
* damaged to read. Asking "did extraction succeed" would answer a different
|
|
573
|
+
* question and quietly strip a user's macros on save.
|
|
574
|
+
*
|
|
575
|
+
* Never throws.
|
|
576
|
+
*/
|
|
577
|
+
export async function findVbaPart(docx) {
|
|
578
|
+
try {
|
|
579
|
+
return await findVbaPartName(openZip(docx));
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Reads the VBA project out of a Word package (`.docm`, `.dotm`, or a `.docx`
|
|
587
|
+
* that happens to carry one).
|
|
588
|
+
*
|
|
589
|
+
* Never throws; `reason: 'no-macros'` is the ordinary answer for a document
|
|
590
|
+
* without macros. Any other failure still reports `partName` when a macro part
|
|
591
|
+
* was located, so a caller can tell "no macros" from "macros we cannot read".
|
|
592
|
+
*/
|
|
593
|
+
export async function extractVbaFromDocx(docx) {
|
|
594
|
+
let archive;
|
|
595
|
+
try {
|
|
596
|
+
archive = openZip(docx);
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
return toFailure(error, 'not-a-package');
|
|
600
|
+
}
|
|
601
|
+
let partName;
|
|
602
|
+
try {
|
|
603
|
+
partName = await findVbaPartName(archive);
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
return toFailure(error, 'unreadable');
|
|
607
|
+
}
|
|
608
|
+
if (!partName) {
|
|
609
|
+
return { ok: false, reason: 'no-macros', message: 'The document contains no macro project.' };
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const bin = await archive.read(partName);
|
|
613
|
+
return extractVbaProject(bin, partName);
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
return toFailure(error, 'unreadable', partName);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Locates the macro part. The relationship graph is authoritative — the part
|
|
621
|
+
* may legally be named anything, so matching on `vbaProject.bin` is only a
|
|
622
|
+
* last resort for packages whose relationships are damaged.
|
|
623
|
+
*/
|
|
624
|
+
async function findVbaPartName(archive) {
|
|
625
|
+
const fromRelationships = await findVbaPartViaRelationships(archive);
|
|
626
|
+
if (fromRelationships && archive.has(fromRelationships))
|
|
627
|
+
return fromRelationships;
|
|
628
|
+
const fromContentTypes = await findVbaPartViaContentTypes(archive);
|
|
629
|
+
if (fromContentTypes && archive.has(fromContentTypes))
|
|
630
|
+
return fromContentTypes;
|
|
631
|
+
const byName = archive.entries.find((entry) => /(^|\/)vbaProject\.bin$/i.test(entry.name));
|
|
632
|
+
return byName?.name ?? null;
|
|
633
|
+
}
|
|
634
|
+
async function findVbaPartViaRelationships(archive) {
|
|
635
|
+
const mainPart = await findMainDocumentPart(archive);
|
|
636
|
+
if (!mainPart)
|
|
637
|
+
return null;
|
|
638
|
+
const slash = mainPart.lastIndexOf('/');
|
|
639
|
+
const baseDir = slash < 0 ? '' : mainPart.slice(0, slash);
|
|
640
|
+
const fileName = slash < 0 ? mainPart : mainPart.slice(slash + 1);
|
|
641
|
+
const relsPath = `${baseDir ? `${baseDir}/` : ''}_rels/${fileName}.rels`;
|
|
642
|
+
if (!archive.has(relsPath))
|
|
643
|
+
return null;
|
|
644
|
+
const target = findRelationshipTarget(await readText(archive, relsPath), VBA_RELATIONSHIP_TYPE);
|
|
645
|
+
return target ? resolvePartPath(baseDir, target) : null;
|
|
646
|
+
}
|
|
647
|
+
async function findMainDocumentPart(archive) {
|
|
648
|
+
if (!archive.has('_rels/.rels'))
|
|
649
|
+
return null;
|
|
650
|
+
const target = findRelationshipTarget(await readText(archive, '_rels/.rels'), OFFICE_DOCUMENT_RELATIONSHIP_TYPE);
|
|
651
|
+
return target ? resolvePartPath('', target) : null;
|
|
652
|
+
}
|
|
653
|
+
async function findVbaPartViaContentTypes(archive) {
|
|
654
|
+
if (!archive.has('[Content_Types].xml'))
|
|
655
|
+
return null;
|
|
656
|
+
const xml = await readText(archive, '[Content_Types].xml');
|
|
657
|
+
for (const tag of findTags(xml, 'Override')) {
|
|
658
|
+
if (!attribute(tag, 'ContentType')?.includes('vbaProject'))
|
|
659
|
+
continue;
|
|
660
|
+
const part = attribute(tag, 'PartName');
|
|
661
|
+
if (part)
|
|
662
|
+
return part.replace(/^\/+/, '');
|
|
663
|
+
}
|
|
664
|
+
// A `Default` mapping names an extension, not a part — so it only tells us
|
|
665
|
+
// which entry to look for.
|
|
666
|
+
for (const tag of findTags(xml, 'Default')) {
|
|
667
|
+
if (!attribute(tag, 'ContentType')?.includes('vbaProject'))
|
|
668
|
+
continue;
|
|
669
|
+
const extension = attribute(tag, 'Extension');
|
|
670
|
+
if (!extension)
|
|
671
|
+
continue;
|
|
672
|
+
const suffix = `.${extension.toLowerCase()}`;
|
|
673
|
+
const match = archive.entries.find((entry) => entry.name.toLowerCase().endsWith(suffix));
|
|
674
|
+
if (match)
|
|
675
|
+
return match.name;
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* The target of the first relationship of a given type.
|
|
681
|
+
*
|
|
682
|
+
* Hand-parsed rather than DOM-parsed because `DOMParser` does not exist in
|
|
683
|
+
* Node, and a miss is safe — the caller falls back to another lookup.
|
|
684
|
+
*/
|
|
685
|
+
function findRelationshipTarget(xml, type) {
|
|
686
|
+
for (const tag of findTags(xml, 'Relationship')) {
|
|
687
|
+
if (attribute(tag, 'Type') !== type)
|
|
688
|
+
continue;
|
|
689
|
+
// External targets point outside the package — never a macro part.
|
|
690
|
+
if (attribute(tag, 'TargetMode')?.toLowerCase() === 'external')
|
|
691
|
+
continue;
|
|
692
|
+
const target = attribute(tag, 'Target');
|
|
693
|
+
if (target)
|
|
694
|
+
return target;
|
|
695
|
+
}
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
const XML_LIMITS = {
|
|
699
|
+
/** How much of a package part is parsed as XML. Real `.rels` are tiny. */
|
|
700
|
+
maxTextBytes: 1_000_000,
|
|
701
|
+
maxTagLength: 2_048,
|
|
702
|
+
maxTags: 5_000,
|
|
703
|
+
};
|
|
704
|
+
/**
|
|
705
|
+
* Every `<name …>` tag in the document, each bounded in length.
|
|
706
|
+
*
|
|
707
|
+
* Scanned with `indexOf` rather than a regex on purpose. The obvious pattern
|
|
708
|
+
* for this job is `/<Relationship\b[^>]*>/g`, and it is quadratic: `[^>]*`
|
|
709
|
+
* cannot match `>`, so on input full of `<Relationship` and containing no
|
|
710
|
+
* `>` at all, every occurrence is a candidate start that consumes to the end
|
|
711
|
+
* of the input and then backtracks a character at a time. A 60 MB part built
|
|
712
|
+
* that way — which compresses to a few kilobytes inside a `.docm` — is hours
|
|
713
|
+
* of wall-clock time in a frozen tab. `indexOf` has no backtracking, so this
|
|
714
|
+
* is linear in the input no matter what the input is.
|
|
715
|
+
*/
|
|
716
|
+
function findTags(xml, name) {
|
|
717
|
+
const found = [];
|
|
718
|
+
const needle = `<${name}`;
|
|
719
|
+
let at = 0;
|
|
720
|
+
while (found.length < XML_LIMITS.maxTags) {
|
|
721
|
+
const start = xml.indexOf(needle, at);
|
|
722
|
+
if (start < 0)
|
|
723
|
+
break;
|
|
724
|
+
at = start + needle.length;
|
|
725
|
+
// The element name has to end here, so `<Relationship` does not match
|
|
726
|
+
// `<Relationships`.
|
|
727
|
+
const following = xml[at];
|
|
728
|
+
if (following !== undefined && following !== '/' && following !== '>' && !/\s/.test(following)) {
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const end = xml.indexOf('>', at);
|
|
732
|
+
if (end < 0)
|
|
733
|
+
break;
|
|
734
|
+
if (end - start <= XML_LIMITS.maxTagLength)
|
|
735
|
+
found.push(xml.slice(start, end + 1));
|
|
736
|
+
at = end + 1;
|
|
737
|
+
}
|
|
738
|
+
return found;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* One attribute's value from a single tag. Safe to run a regex here: the tag
|
|
742
|
+
* is already bounded by `XML_LIMITS.maxTagLength`, and `name` is always a
|
|
743
|
+
* literal from this file, never anything file-derived.
|
|
744
|
+
*/
|
|
745
|
+
function attribute(tag, name) {
|
|
746
|
+
const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`, 'i').exec(tag);
|
|
747
|
+
return match?.[1] ?? null;
|
|
748
|
+
}
|
|
749
|
+
/** Resolves a relationship target against the part's folder. */
|
|
750
|
+
function resolvePartPath(baseDir, target) {
|
|
751
|
+
if (target.startsWith('/'))
|
|
752
|
+
return target.replace(/^\/+/, '');
|
|
753
|
+
const segments = baseDir ? baseDir.split('/') : [];
|
|
754
|
+
for (const segment of target.split('/')) {
|
|
755
|
+
if (segment === '' || segment === '.')
|
|
756
|
+
continue;
|
|
757
|
+
if (segment === '..')
|
|
758
|
+
segments.pop();
|
|
759
|
+
else
|
|
760
|
+
segments.push(segment);
|
|
761
|
+
}
|
|
762
|
+
return segments.join('/');
|
|
763
|
+
}
|
|
764
|
+
async function readText(archive, name) {
|
|
765
|
+
// A tight cap, passed down so the reader abandons an oversized part while
|
|
766
|
+
// decompressing instead of handing back megabytes of XML to scan.
|
|
767
|
+
const bytes = await archive.read(name, { maxBytes: XML_LIMITS.maxTextBytes });
|
|
768
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
769
|
+
}
|
|
770
|
+
/* ------------------------------------------------------------------ *
|
|
771
|
+
* failures
|
|
772
|
+
* ------------------------------------------------------------------ */
|
|
773
|
+
function toFailure(error, fallbackReason, partName) {
|
|
774
|
+
const located = partName === undefined ? {} : { partName };
|
|
775
|
+
if (isVbaParseError(error)) {
|
|
776
|
+
const reason = error.code === 'too-large' ? 'too-large' : error.code === 'unsupported' ? 'unsupported' : fallbackReason;
|
|
777
|
+
return { ok: false, reason, message: error.message, ...located };
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
ok: false,
|
|
781
|
+
reason: 'unreadable',
|
|
782
|
+
message: error instanceof Error ? error.message : 'The macro project could not be read.',
|
|
783
|
+
...located,
|
|
784
|
+
};
|
|
785
|
+
}
|