@patrickdk77/office-defuse 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +278 -36
- package/dist/biff.d.ts +62 -0
- package/dist/biff.js +95 -0
- package/dist/cfb-writer.d.ts +25 -0
- package/dist/cfb-writer.js +314 -0
- package/dist/cfb.d.ts +58 -0
- package/dist/cfb.js +456 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +357 -0
- package/dist/contained.d.ts +46 -0
- package/dist/contained.js +152 -0
- package/dist/context.d.ts +140 -17
- package/dist/context.js +135 -37
- package/dist/crc32.d.ts +8 -2
- package/dist/crc32.js +44 -4
- package/dist/engine.d.ts +12 -1
- package/dist/engine.js +336 -42
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +27 -1
- package/dist/fields.d.ts +36 -0
- package/dist/fields.js +139 -0
- package/dist/findings.d.ts +2 -1
- package/dist/findings.js +44 -9
- package/dist/flat.d.ts +33 -0
- package/dist/flat.js +486 -0
- package/dist/formats.d.ts +7 -0
- package/dist/formats.js +5 -1
- package/dist/formulas.d.ts +27 -0
- package/dist/formulas.js +157 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +8 -1
- package/dist/index.mjs +1 -1
- package/dist/io.d.ts +62 -1
- package/dist/io.js +245 -1
- package/dist/links.d.ts +27 -0
- package/dist/links.js +106 -1
- package/dist/msg.d.ts +8 -0
- package/dist/msg.js +251 -0
- package/dist/objects.d.ts +45 -0
- package/dist/objects.js +204 -0
- package/dist/odf.d.ts +66 -2
- package/dist/odf.js +181 -64
- package/dist/offcrypto.d.ts +54 -0
- package/dist/offcrypto.js +647 -0
- package/dist/ole.d.ts +12 -0
- package/dist/ole.js +139 -0
- package/dist/oleobj.d.ts +85 -0
- package/dist/oleobj.js +215 -0
- package/dist/ooxml.d.ts +83 -2
- package/dist/ooxml.js +358 -145
- package/dist/package.d.ts +3 -0
- package/dist/package.js +21 -0
- package/dist/plan.d.ts +104 -0
- package/dist/plan.js +73 -0
- package/dist/plugins.d.ts +8 -0
- package/dist/plugins.js +60 -0
- package/dist/ppt.d.ts +8 -0
- package/dist/ppt.js +281 -0
- package/dist/rewrite-cfb.d.ts +14 -0
- package/dist/rewrite-cfb.js +52 -0
- package/dist/rewrite-rtf.d.ts +9 -0
- package/dist/rewrite-rtf.js +45 -0
- package/dist/rewrite-xml.d.ts +36 -0
- package/dist/rewrite-xml.js +341 -0
- package/dist/rewrite-zip.d.ts +8 -0
- package/dist/rewrite-zip.js +498 -0
- package/dist/rtf.d.ts +8 -0
- package/dist/rtf.js +712 -0
- package/dist/rtfcomp.d.ts +7 -0
- package/dist/rtfcomp.js +80 -0
- package/dist/score.js +1 -1
- package/dist/sniff.d.ts +5 -0
- package/dist/sniff.js +38 -0
- package/dist/types.d.ts +66 -2
- package/dist/types.js +33 -0
- package/dist/vsd.d.ts +13 -0
- package/dist/vsd.js +689 -0
- package/dist/word.d.ts +9 -0
- package/dist/word.js +194 -0
- package/dist/xls.d.ts +27 -0
- package/dist/xls.js +208 -0
- package/dist/xml.d.ts +30 -0
- package/dist/xml.js +45 -23
- package/dist/zip-writer.d.ts +4 -0
- package/dist/zip-writer.js +20 -7
- package/dist/zip.d.ts +9 -2
- package/dist/zip.js +61 -4
- package/package.json +6 -3
package/dist/engine.js
CHANGED
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.inspectOfficeSource = inspectOfficeSource;
|
|
4
4
|
exports.inspectOffice = inspectOffice;
|
|
5
|
+
exports.disarmOfficeSource = disarmOfficeSource;
|
|
6
|
+
exports.disarmAtDepth = disarmAtDepth;
|
|
7
|
+
exports.disarmOffice = disarmOffice;
|
|
8
|
+
const cfb_1 = require("./cfb");
|
|
5
9
|
const context_1 = require("./context");
|
|
6
10
|
const errors_1 = require("./errors");
|
|
7
11
|
const findings_1 = require("./findings");
|
|
12
|
+
const flat_1 = require("./flat");
|
|
8
13
|
const io_1 = require("./io");
|
|
9
|
-
const
|
|
10
|
-
const
|
|
14
|
+
const ole_1 = require("./ole");
|
|
15
|
+
const package_1 = require("./package");
|
|
16
|
+
const rewrite_cfb_1 = require("./rewrite-cfb");
|
|
17
|
+
const rewrite_rtf_1 = require("./rewrite-rtf");
|
|
18
|
+
const rewrite_zip_1 = require("./rewrite-zip");
|
|
19
|
+
const rtf_1 = require("./rtf");
|
|
11
20
|
const score_1 = require("./score");
|
|
12
21
|
const types_1 = require("./types");
|
|
13
22
|
const zip_1 = require("./zip");
|
|
@@ -20,6 +29,7 @@ const DEFAULT_LIMITS = {
|
|
|
20
29
|
xmlEntityDepth: 16,
|
|
21
30
|
xmlEntityExpansions: 100_000,
|
|
22
31
|
xmlEntitySize: 16 * 1024 * 1024,
|
|
32
|
+
rtfDepth: 256,
|
|
23
33
|
};
|
|
24
34
|
function resolveLimits(given = {}) {
|
|
25
35
|
return {
|
|
@@ -31,21 +41,34 @@ function resolveLimits(given = {}) {
|
|
|
31
41
|
xmlEntityDepth: given.xmlEntityDepth ?? DEFAULT_LIMITS.xmlEntityDepth,
|
|
32
42
|
xmlEntityExpansions: given.xmlEntityExpansions ?? DEFAULT_LIMITS.xmlEntityExpansions,
|
|
33
43
|
xmlEntitySize: given.xmlEntitySize ?? DEFAULT_LIMITS.xmlEntitySize,
|
|
44
|
+
rtfDepth: given.rtfDepth ?? DEFAULT_LIMITS.rtfDepth,
|
|
34
45
|
fileSize: given.fileSize,
|
|
35
46
|
timeMs: given.timeMs,
|
|
47
|
+
nestingDepth: given.nestingDepth,
|
|
36
48
|
};
|
|
37
49
|
}
|
|
50
|
+
const xmlLimitsOf = (limits) => ({
|
|
51
|
+
depth: limits.xmlDepth,
|
|
52
|
+
tokenSize: limits.xmlTokenSize,
|
|
53
|
+
entityDepth: limits.xmlEntityDepth,
|
|
54
|
+
entityExpansions: limits.xmlEntityExpansions,
|
|
55
|
+
entitySize: limits.xmlEntitySize,
|
|
56
|
+
});
|
|
57
|
+
const PLUGIN_KEPT = new Set([types_1.OfficeDetail.PluginPassed, types_1.OfficeDetail.PluginScrubbed]);
|
|
38
58
|
/**
|
|
39
59
|
* `incomplete` marks a run that stopped before it read the whole package. It is rejected without a score, whatever
|
|
40
|
-
* the actions say, because an override can make the finding that stopped it look harmless.
|
|
60
|
+
* the actions say, because an override can make the finding that stopped it look harmless. In the check of an output,
|
|
61
|
+
* `isOutput`, the files plugins kept count in the score; in an upload they do not, and a file a plugin cleaned makes
|
|
62
|
+
* the upload strippable, since the output must be written.
|
|
41
63
|
*/
|
|
42
|
-
function finish(findings, options, incomplete, format) {
|
|
64
|
+
function finish(findings, options, incomplete, format, isOutput = false, superseded) {
|
|
43
65
|
const named = { format: format?.format ?? '', mimeType: format?.mimeType ?? '' };
|
|
44
66
|
if (incomplete)
|
|
45
67
|
return { status: 'rejected', score: null, risk: types_1.OfficeRisk.Unknown, findings, ...named };
|
|
46
68
|
const rejected = findings.some(f => f.action === 'reject');
|
|
47
|
-
const strippable = findings.some(f => f.action === 'strip');
|
|
48
|
-
const
|
|
69
|
+
const strippable = findings.some(f => f.action === 'strip' || (!isOutput && f.detail === types_1.OfficeDetail.PluginScrubbed));
|
|
70
|
+
const scored = findings.filter(f => !superseded?.has(f) && (isOutput || !PLUGIN_KEPT.has(f.detail)));
|
|
71
|
+
const { score, risk } = (0, score_1.scoreFindings)(scored, options);
|
|
49
72
|
return { status: rejected ? 'rejected' : strippable ? 'strippable' : 'clean', score, risk, findings, ...named };
|
|
50
73
|
}
|
|
51
74
|
/**
|
|
@@ -82,70 +105,341 @@ function guarded(source) {
|
|
|
82
105
|
};
|
|
83
106
|
return g;
|
|
84
107
|
}
|
|
85
|
-
|
|
86
|
-
|
|
108
|
+
const ambiguous = (reason) => new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.AmbiguousFormat, `The file is ${reason}`, { reason });
|
|
109
|
+
/**
|
|
110
|
+
* Reads the file's container. A file that starts with the compound file signature is read as one, and one that
|
|
111
|
+
* starts with {\rt is RTF, which is how Word tells them. The ZIP reader runs too, from the end: a file both read is
|
|
112
|
+
* ambiguous, since Office goes by the first bytes and a ZIP reader by the last. A compound file that does not read is
|
|
113
|
+
* a ZIP archive with data in front, if the ZIP reader read one. Word reads any file that starts with {\rt as RTF, so
|
|
114
|
+
* that does not fail. A file that starts as XML is a flat OpenDocument file or a Flat OPC package, and one that is a
|
|
115
|
+
* ZIP archive as well is ambiguous.
|
|
116
|
+
*/
|
|
117
|
+
async function openContainer(src, size, limits, settings) {
|
|
118
|
+
const head = await src.read(0, Math.min(1024, size));
|
|
119
|
+
const cfbSignature = head.length >= 8 && cfb_1.CFB_SIGNATURE.every((b, i) => head[i] === b);
|
|
120
|
+
const rtfSignature = Buffer.from(head.subarray(0, 4)).toString('latin1') === '{\\rt';
|
|
121
|
+
const xml = !cfbSignature && !rtfSignature && (0, flat_1.startsAsXml)(head);
|
|
122
|
+
const probe = cfbSignature || rtfSignature || xml
|
|
123
|
+
? await zip_1.ZipArchive.open(src, limits, settings.deadline).catch((e) => {
|
|
124
|
+
if (e instanceof errors_1.StopError)
|
|
125
|
+
return undefined;
|
|
126
|
+
throw e;
|
|
127
|
+
})
|
|
128
|
+
: undefined;
|
|
129
|
+
if (rtfSignature) {
|
|
130
|
+
if (probe)
|
|
131
|
+
throw ambiguous('both RTF and a ZIP archive');
|
|
132
|
+
return { kind: 'rtf' };
|
|
133
|
+
}
|
|
134
|
+
if (xml) {
|
|
135
|
+
if (probe)
|
|
136
|
+
throw ambiguous('both XML and a ZIP archive');
|
|
137
|
+
return { kind: 'xml' };
|
|
138
|
+
}
|
|
139
|
+
if (cfbSignature) {
|
|
140
|
+
try {
|
|
141
|
+
const cfb = await cfb_1.CompoundFile.open(src, settings, settings.deadline);
|
|
142
|
+
if (probe)
|
|
143
|
+
throw ambiguous('both a compound file and a ZIP archive');
|
|
144
|
+
return { kind: 'cfb', cfb };
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
if (!probe || !(e instanceof errors_1.StopError) || e.category !== types_1.OfficeCategory.Container)
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { kind: 'zip', zip: probe ?? (await zip_1.ZipArchive.open(src, limits, settings.deadline)) };
|
|
152
|
+
}
|
|
153
|
+
/** A run of the engine over one file at `depth`, with the deadline the whole upload shares. */
|
|
154
|
+
function newRun(options, depth, deadline, verify) {
|
|
155
|
+
return {
|
|
156
|
+
options,
|
|
157
|
+
depth,
|
|
158
|
+
deadline,
|
|
159
|
+
temp: new io_1.TempDir(options.tempDir),
|
|
160
|
+
threshold: options.memoryThreshold ?? 8 * 1024 * 1024,
|
|
161
|
+
retained: 0,
|
|
162
|
+
verify,
|
|
163
|
+
kept: new Map(),
|
|
164
|
+
held: [],
|
|
165
|
+
superseded: new WeakSet(),
|
|
166
|
+
sources: [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Releases what a run holds: the copies plugins returned and the temporary directory. */
|
|
170
|
+
async function endRun(run) {
|
|
171
|
+
try {
|
|
172
|
+
for (const s of run.sources.splice(0))
|
|
173
|
+
await s.close?.();
|
|
174
|
+
for (const h of run.held.splice(0))
|
|
175
|
+
await h.dispose();
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await run.temp.cleanup();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** The finding a limit error from this package or any other gives at depth 0. */
|
|
182
|
+
function limitFinding(factory, e) {
|
|
183
|
+
return factory.make(types_1.OfficeCategory.Limit, e.limit === 'nesting' ? types_1.OfficeDetail.NestingDepth : types_1.OfficeDetail.Time);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Inspects `source` and leaves what the rewrite needs in `run.output`. It throws an I/O error instead of reporting a
|
|
187
|
+
* verdict on the file, and below depth 0 it throws a limit error from the family, which stops the whole upload.
|
|
188
|
+
*/
|
|
189
|
+
async function analyze(run, source) {
|
|
190
|
+
const options = run.options;
|
|
87
191
|
const limits = resolveLimits(options.limits);
|
|
88
|
-
const deadline =
|
|
192
|
+
const deadline = run.deadline;
|
|
89
193
|
const factory = new findings_1.FindingFactory(options.actionOverrides);
|
|
90
194
|
const findings = [];
|
|
91
195
|
const src = guarded(source);
|
|
92
|
-
|
|
196
|
+
const password = options.password;
|
|
197
|
+
const settings = {
|
|
198
|
+
entries: limits.entries,
|
|
199
|
+
decompressedBytes: limits.decompressedBytes,
|
|
200
|
+
totalDecompressedBytes: limits.totalDecompressedBytes,
|
|
201
|
+
rtfDepth: limits.rtfDepth,
|
|
202
|
+
deadline,
|
|
203
|
+
passwords: typeof password === 'string' ? [password] : (password ?? []),
|
|
204
|
+
};
|
|
205
|
+
const ctx = new context_1.RunContext(factory, xmlLimitsOf(limits), settings, run);
|
|
206
|
+
const isOutput = run.verify !== undefined;
|
|
93
207
|
try {
|
|
208
|
+
if (limits.nestingDepth !== undefined && run.depth > limits.nestingDepth)
|
|
209
|
+
throw new errors_1.NestingLimitError('Nesting limit exceeded');
|
|
94
210
|
const size = await src.size();
|
|
95
211
|
if (limits.fileSize !== undefined && size > limits.fileSize)
|
|
96
212
|
throw new errors_1.StopError(types_1.OfficeCategory.Limit, types_1.OfficeDetail.FileSize, `${size} bytes`, { size, limit: limits.fileSize });
|
|
97
213
|
(0, errors_1.checkTime)(deadline);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
214
|
+
const container = await openContainer(src, size, limits, settings);
|
|
215
|
+
if (container.kind === 'rtf')
|
|
216
|
+
await (0, rtf_1.inspectRtf)(ctx, src, size);
|
|
217
|
+
else if (container.kind === 'xml') {
|
|
218
|
+
const output = await (0, flat_1.inspectFlat)(ctx, src, size, limits);
|
|
219
|
+
if (output && ctx.format)
|
|
220
|
+
run.output = output;
|
|
221
|
+
}
|
|
222
|
+
else if (container.kind === 'cfb') {
|
|
223
|
+
for (const n of container.cfb.notes)
|
|
224
|
+
findings.push(factory.make(types_1.OfficeCategory.Container, n.detail, undefined, { bytes: n.bytes }));
|
|
225
|
+
await (0, ole_1.inspectCompound)(ctx, container.cfb);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
const zip = container.zip;
|
|
229
|
+
for (const n of zip.notes)
|
|
230
|
+
findings.push(factory.make(types_1.OfficeCategory.Container, n.detail, undefined, n.offset === undefined ? { bytes: n.bytes } : { bytes: n.bytes, offset: n.offset }));
|
|
231
|
+
const zctx = ctx.withZip(zip);
|
|
232
|
+
await (0, package_1.inspectPackage)(zctx);
|
|
233
|
+
if (ctx.format)
|
|
234
|
+
run.output = { kind: 'zip', zip, plan: zctx.plan, format: ctx.format };
|
|
235
|
+
}
|
|
118
236
|
findings.push(...ctx.findings);
|
|
119
|
-
|
|
237
|
+
if (run.temp.ioError !== undefined)
|
|
238
|
+
throw run.temp.ioError;
|
|
239
|
+
return finish(findings, options, false, ctx.format, isOutput, run.superseded);
|
|
120
240
|
}
|
|
121
241
|
catch (e) {
|
|
122
242
|
if (src.failure !== undefined)
|
|
123
243
|
throw src.failure;
|
|
124
|
-
if (
|
|
125
|
-
|
|
244
|
+
if (run.temp.ioError !== undefined)
|
|
245
|
+
throw run.temp.ioError;
|
|
246
|
+
run.output = undefined;
|
|
247
|
+
findings.push(...ctx.findings);
|
|
126
248
|
if (e instanceof errors_1.StopError) {
|
|
127
249
|
findings.push(factory.make(e.category, e.detail, e.location ?? (typeof e.data?.name === 'string' ? e.data.name : undefined), { ...e.data, reason: e.data?.reason ?? e.message }));
|
|
128
250
|
}
|
|
129
|
-
else if (e
|
|
130
|
-
// The family's time
|
|
131
|
-
|
|
251
|
+
else if ((0, errors_1.stopsTree)(e) === 'DEFUSE_LIMIT') {
|
|
252
|
+
// The family's time and nesting limits, from this package or any other, stop the whole upload.
|
|
253
|
+
if (run.depth > 0)
|
|
254
|
+
throw e;
|
|
255
|
+
findings.push(limitFinding(factory, e));
|
|
256
|
+
}
|
|
257
|
+
else if ((0, errors_1.isIoError)(e)) {
|
|
258
|
+
throw e;
|
|
132
259
|
}
|
|
133
260
|
else {
|
|
134
261
|
// Anything unexpected rejects the file rather than escaping as an exception.
|
|
135
262
|
findings.push(factory.make(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unreadable, undefined, { reason: String(e?.message ?? e).slice(0, 200) }));
|
|
136
263
|
}
|
|
137
|
-
return finish(findings, options, true, ctx
|
|
264
|
+
return finish(findings, options, true, ctx.format, isOutput);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function closeSource(source) {
|
|
268
|
+
try {
|
|
269
|
+
await source.close?.();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// The verdict stands whether or not the source closes.
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Inspects an Office or OpenDocument file without changing it. The source is closed when the run ends. */
|
|
276
|
+
async function inspectOfficeSource(source, options = {}) {
|
|
277
|
+
const deadline = options.limits?.timeMs !== undefined ? Date.now() + options.limits.timeMs : undefined;
|
|
278
|
+
const run = newRun(options, 0, deadline);
|
|
279
|
+
try {
|
|
280
|
+
return await analyze(run, source);
|
|
138
281
|
}
|
|
139
282
|
finally {
|
|
283
|
+
await closeSource(source);
|
|
284
|
+
await endRun(run);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Inspects Office or OpenDocument bytes. */
|
|
288
|
+
async function inspectOffice(bytes, options = {}) {
|
|
289
|
+
return inspectOfficeSource((0, io_1.bufferSource)(bytes), options);
|
|
290
|
+
}
|
|
291
|
+
/** Copies to the caller's sink. On failure it aborts the sink instead of closing it, so a partial copy does not look whole. */
|
|
292
|
+
async function deliver(from, sink) {
|
|
293
|
+
try {
|
|
294
|
+
await (0, io_1.copyToSink)(from, sink);
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
140
297
|
try {
|
|
141
|
-
await
|
|
298
|
+
await sink.abort?.(e);
|
|
142
299
|
}
|
|
143
300
|
catch {
|
|
144
|
-
|
|
301
|
+
/* the copy's error is the one to report */
|
|
145
302
|
}
|
|
303
|
+
throw e;
|
|
146
304
|
}
|
|
305
|
+
await sink.close();
|
|
147
306
|
}
|
|
148
|
-
/**
|
|
149
|
-
async function
|
|
150
|
-
|
|
307
|
+
/** Writes the rewrite of what the run read. */
|
|
308
|
+
async function write(run, output, sink, source) {
|
|
309
|
+
const resolved = resolveLimits(run.options.limits);
|
|
310
|
+
const limits = xmlLimitsOf(resolved);
|
|
311
|
+
if (output.kind === 'zip')
|
|
312
|
+
await (0, rewrite_zip_1.writePackage)(run, output.zip, output.plan, output.format, limits, sink);
|
|
313
|
+
else if (output.kind === 'cfb')
|
|
314
|
+
await (0, rewrite_cfb_1.writeCompound)(run, output.cfb, output.plan, sink);
|
|
315
|
+
else if (output.kind === 'flatodf')
|
|
316
|
+
await (0, flat_1.writeFlatOdf)(run, source, output.plan, limits, sink);
|
|
317
|
+
else if (output.kind === 'flatopc')
|
|
318
|
+
await (0, flat_1.writeFlatOpc)(run, output, limits, resolved, sink);
|
|
319
|
+
else
|
|
320
|
+
await (0, rewrite_rtf_1.writeRtf)(run, source, output.size, output.cuts, sink);
|
|
321
|
+
}
|
|
322
|
+
/** Rejects the upload, keeping its findings and, unless a limit stopped the run, its score. */
|
|
323
|
+
function refuse(before, finding, limited) {
|
|
324
|
+
before.findings.push(finding);
|
|
325
|
+
before.status = 'rejected';
|
|
326
|
+
if (limited) {
|
|
327
|
+
before.score = null;
|
|
328
|
+
before.risk = types_1.OfficeRisk.Unknown;
|
|
329
|
+
}
|
|
330
|
+
return { status: 'rejected', before, removed: [] };
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Writes a defused copy of `source` to `sink`. Nothing reaches the sink unless the output passes inspection. The
|
|
334
|
+
* source is closed when the run ends.
|
|
335
|
+
*/
|
|
336
|
+
async function disarmOfficeSource(source, sink, options = {}) {
|
|
337
|
+
return disarmAtDepth(source, sink, options, 0);
|
|
338
|
+
}
|
|
339
|
+
/** Internal entry that carries the depth of a file inside another, and the deadline it shares with the upload. */
|
|
340
|
+
async function disarmAtDepth(source, sink, options, depth, deadline = options.limits?.timeMs !== undefined ? Date.now() + options.limits.timeMs : undefined) {
|
|
341
|
+
const run = newRun(options, depth, deadline);
|
|
342
|
+
const factory = new findings_1.FindingFactory(options.actionOverrides);
|
|
343
|
+
let input = source;
|
|
344
|
+
try {
|
|
345
|
+
let before;
|
|
346
|
+
try {
|
|
347
|
+
// A caller's source can change between reads, so the inspection and the rewrite read one snapshot. A file
|
|
348
|
+
// inside another already has a source of the engine's, and one over the size limit is rejected unread.
|
|
349
|
+
const fileSize = options.limits?.fileSize;
|
|
350
|
+
if (depth === 0 && (fileSize === undefined || (await source.size()) <= fileSize)) {
|
|
351
|
+
const snapshot = new io_1.SpillSink(run.temp, run.threshold);
|
|
352
|
+
run.held.push(snapshot);
|
|
353
|
+
try {
|
|
354
|
+
await (0, io_1.copyToSink)(source, snapshot, () => (0, errors_1.checkTime)(deadline));
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
await snapshot.close();
|
|
358
|
+
}
|
|
359
|
+
input = snapshot.source();
|
|
360
|
+
}
|
|
361
|
+
before = await analyze(run, input);
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
if ((0, errors_1.stopsTree)(e) !== 'DEFUSE_LIMIT' || depth > 0)
|
|
365
|
+
throw e;
|
|
366
|
+
before = finish([limitFinding(factory, e)], options, true);
|
|
367
|
+
}
|
|
368
|
+
const output = run.output;
|
|
369
|
+
if (before.status === 'rejected' || !output)
|
|
370
|
+
return { status: 'rejected', before, removed: [] };
|
|
371
|
+
const out = new io_1.SpillSink(run.temp, run.threshold);
|
|
372
|
+
run.held.push(out);
|
|
373
|
+
try {
|
|
374
|
+
await write(run, output, out, input);
|
|
375
|
+
await out.close();
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
await out.close().catch(() => {
|
|
379
|
+
// The write's failure, handled below, is the one that counts.
|
|
380
|
+
});
|
|
381
|
+
if (run.temp.ioError !== undefined)
|
|
382
|
+
throw run.temp.ioError;
|
|
383
|
+
if ((0, errors_1.isIoError)(e))
|
|
384
|
+
throw e;
|
|
385
|
+
if ((0, errors_1.stopsTree)(e) === 'DEFUSE_LIMIT') {
|
|
386
|
+
if (depth > 0)
|
|
387
|
+
throw e;
|
|
388
|
+
return refuse(before, limitFinding(factory, e), true);
|
|
389
|
+
}
|
|
390
|
+
return refuse(before, factory.make(types_1.OfficeCategory.Processing, types_1.OfficeDetail.VerificationFailed, undefined, { reasons: `write failed: ${String(e?.message ?? e).slice(0, 100)}` }), false);
|
|
391
|
+
}
|
|
392
|
+
// The output is checked with the caller's overrides and limits, and the plugins' verdicts in place of the plugins.
|
|
393
|
+
const verifyOptions = {
|
|
394
|
+
actionOverrides: options.actionOverrides,
|
|
395
|
+
scoreWeights: options.scoreWeights,
|
|
396
|
+
scoreBands: options.scoreBands,
|
|
397
|
+
tempDir: options.tempDir,
|
|
398
|
+
memoryThreshold: options.memoryThreshold,
|
|
399
|
+
limits: { ...options.limits, fileSize: undefined, timeMs: undefined, nestingDepth: undefined },
|
|
400
|
+
};
|
|
401
|
+
const outSource = out.source();
|
|
402
|
+
try {
|
|
403
|
+
const check = newRun(verifyOptions, depth, deadline, run.kept);
|
|
404
|
+
let after;
|
|
405
|
+
try {
|
|
406
|
+
after = await analyze(check, outSource);
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
await endRun(check);
|
|
410
|
+
}
|
|
411
|
+
const reasons = after.findings.filter(f => f.action !== 'info').map(f => `${f.category}/${f.detail}`);
|
|
412
|
+
// A check that stopped early verified nothing, even when an override made the finding that stopped it info.
|
|
413
|
+
if (after.status === 'rejected' && !reasons.length)
|
|
414
|
+
reasons.push('the output check stopped early');
|
|
415
|
+
if (after.format !== before.format)
|
|
416
|
+
reasons.push(`format ${before.format} -> ${after.format}`);
|
|
417
|
+
if (reasons.length)
|
|
418
|
+
return refuse(before, factory.make(types_1.OfficeCategory.Processing, types_1.OfficeDetail.VerificationFailed, undefined, { reasons: [...new Set(reasons)].slice(0, 10).join(', ') }), false);
|
|
419
|
+
await deliver(outSource, sink);
|
|
420
|
+
return { status: before.status === 'clean' ? 'clean' : 'defused', before, after, removed: before.findings.filter(f => f.action === 'strip') };
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
await closeSource(outSource);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
// An I/O error inside a nested file fails the upload's run too, instead of counting as a failed plugin.
|
|
428
|
+
if (depth > 0 && ((0, errors_1.isIoError)(e) || (e !== undefined && e === run.temp.ioError)))
|
|
429
|
+
throw new errors_1.NestedIoError(e);
|
|
430
|
+
throw e;
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
if (input !== source)
|
|
434
|
+
await closeSource(input);
|
|
435
|
+
if (depth === 0)
|
|
436
|
+
await closeSource(source);
|
|
437
|
+
await endRun(run);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** Defuses Office or OpenDocument bytes. `bytes` holds the output unless the file was rejected. */
|
|
441
|
+
async function disarmOffice(bytes, options = {}) {
|
|
442
|
+
const sink = (0, io_1.bufferSink)();
|
|
443
|
+
const result = await disarmOfficeSource((0, io_1.bufferSource)(bytes), sink, options);
|
|
444
|
+
return result.status === 'rejected' ? result : { ...result, bytes: sink.result() };
|
|
151
445
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -4,6 +4,23 @@ export declare class TimeLimitError extends Error {
|
|
|
4
4
|
readonly code = "DEFUSE_LIMIT";
|
|
5
5
|
readonly limit = "time";
|
|
6
6
|
}
|
|
7
|
+
/** A file sits deeper in the upload than `limits.nestingDepth` allows. The code makes every package pass it up. */
|
|
8
|
+
export declare class NestingLimitError extends Error {
|
|
9
|
+
readonly code = "DEFUSE_LIMIT";
|
|
10
|
+
readonly limit = "nesting";
|
|
11
|
+
}
|
|
12
|
+
/** An I/O error inside a nested file's own run, carried up so the upload's run fails on it as on its own. */
|
|
13
|
+
export declare class NestedIoError extends Error {
|
|
14
|
+
readonly code = "DEFUSE_IO";
|
|
15
|
+
constructor(cause: unknown);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The code of an error that stops the whole tree of files, from any package: DEFUSE_LIMIT for a time or nesting
|
|
19
|
+
* limit, DEFUSE_IO for an I/O error. Packages cannot share error classes, so the code alone marks one.
|
|
20
|
+
*/
|
|
21
|
+
export declare function stopsTree(e: unknown): 'DEFUSE_LIMIT' | 'DEFUSE_IO' | undefined;
|
|
22
|
+
/** An error from the operating system, such as EIO or ENOSPC, which says nothing about the file. */
|
|
23
|
+
export declare const isIoError: (e: unknown) => boolean;
|
|
7
24
|
/**
|
|
8
25
|
* A refusal or a limit that stops the run. The engine turns it into the finding it names and rejects the file with
|
|
9
26
|
* no score, whatever the overrides say.
|
package/dist/errors.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.StopError = exports.TimeLimitError = void 0;
|
|
3
|
+
exports.StopError = exports.isIoError = exports.NestedIoError = exports.NestingLimitError = exports.TimeLimitError = void 0;
|
|
4
|
+
exports.stopsTree = stopsTree;
|
|
4
5
|
exports.checkTime = checkTime;
|
|
5
6
|
/** The run passed its deadline. The code makes every package of the family pass it up instead of treating it as one plugin failing. */
|
|
6
7
|
class TimeLimitError extends Error {
|
|
@@ -8,6 +9,31 @@ class TimeLimitError extends Error {
|
|
|
8
9
|
limit = 'time';
|
|
9
10
|
}
|
|
10
11
|
exports.TimeLimitError = TimeLimitError;
|
|
12
|
+
/** A file sits deeper in the upload than `limits.nestingDepth` allows. The code makes every package pass it up. */
|
|
13
|
+
class NestingLimitError extends Error {
|
|
14
|
+
code = 'DEFUSE_LIMIT';
|
|
15
|
+
limit = 'nesting';
|
|
16
|
+
}
|
|
17
|
+
exports.NestingLimitError = NestingLimitError;
|
|
18
|
+
/** An I/O error inside a nested file's own run, carried up so the upload's run fails on it as on its own. */
|
|
19
|
+
class NestedIoError extends Error {
|
|
20
|
+
code = 'DEFUSE_IO';
|
|
21
|
+
constructor(cause) {
|
|
22
|
+
super(String(cause?.message ?? cause), { cause });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
exports.NestedIoError = NestedIoError;
|
|
26
|
+
/**
|
|
27
|
+
* The code of an error that stops the whole tree of files, from any package: DEFUSE_LIMIT for a time or nesting
|
|
28
|
+
* limit, DEFUSE_IO for an I/O error. Packages cannot share error classes, so the code alone marks one.
|
|
29
|
+
*/
|
|
30
|
+
function stopsTree(e) {
|
|
31
|
+
const code = typeof e === 'object' && e !== null ? e.code : undefined;
|
|
32
|
+
return code === 'DEFUSE_LIMIT' || code === 'DEFUSE_IO' ? code : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** An error from the operating system, such as EIO or ENOSPC, which says nothing about the file. */
|
|
35
|
+
const isIoError = (e) => typeof e?.code === 'string' && /^E[A-Z]+$/.test(e.code);
|
|
36
|
+
exports.isIoError = isIoError;
|
|
11
37
|
/**
|
|
12
38
|
* A refusal or a limit that stops the run. The engine turns it into the finding it names and rejects the file with
|
|
13
39
|
* no score, whatever the overrides say.
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RunContext } from './context';
|
|
2
|
+
import type { FieldMarks } from './plan';
|
|
3
|
+
/**
|
|
4
|
+
* Reports a Word field by its code, and tells whether any of its findings strips it. A field whose name holds the
|
|
5
|
+
* content of an external entity has a type the file does not say, as one whose name another field builds does. A
|
|
6
|
+
* HYPERLINK field's result is the text the reader sees, and goes with its tooltip into the label check.
|
|
7
|
+
*/
|
|
8
|
+
export declare function evaluateField(ctx: RunContext, location: string, code: string, computed: boolean, result?: string): boolean;
|
|
9
|
+
export declare class FieldStack {
|
|
10
|
+
private readonly ctx;
|
|
11
|
+
private readonly location;
|
|
12
|
+
private readonly onStrip?;
|
|
13
|
+
private readonly stack;
|
|
14
|
+
/**
|
|
15
|
+
* `onStrip` hears of each field a strip finding names, with the marks the format gave its begin, separator and end,
|
|
16
|
+
* so the rewrite can find the field again.
|
|
17
|
+
*/
|
|
18
|
+
constructor(ctx: RunContext, location: string, onStrip?: ((marks: FieldMarks) => void) | undefined);
|
|
19
|
+
/** A field starts. One that starts before its parent's code has a word in it makes that code at run time. */
|
|
20
|
+
begin(mark?: number): void;
|
|
21
|
+
/** A field whose code is given whole, such as OOXML's fldSimple, whose result follows until simpleEnd(). */
|
|
22
|
+
simpleBegin(code: string, mark?: number): void;
|
|
23
|
+
simpleEnd(): void;
|
|
24
|
+
separate(mark?: number): void;
|
|
25
|
+
end(mark?: number): void;
|
|
26
|
+
private decide;
|
|
27
|
+
/** Whether a field's code is being read now. */
|
|
28
|
+
get inCode(): boolean;
|
|
29
|
+
/** Text of the innermost field's code. White space before the first word counts for nothing, so no amount of it can push the field's name out. */
|
|
30
|
+
text(t: string): void;
|
|
31
|
+
/** Text of the innermost field's result, the text the reader sees. */
|
|
32
|
+
result(t: string): void;
|
|
33
|
+
/** A document can end inside a field. Word still reads the code it has. */
|
|
34
|
+
finish(): void;
|
|
35
|
+
private nest;
|
|
36
|
+
}
|