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