@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.
Files changed (88) hide show
  1. package/README.md +278 -36
  2. package/dist/biff.d.ts +62 -0
  3. package/dist/biff.js +95 -0
  4. package/dist/cfb-writer.d.ts +25 -0
  5. package/dist/cfb-writer.js +314 -0
  6. package/dist/cfb.d.ts +58 -0
  7. package/dist/cfb.js +456 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +357 -0
  10. package/dist/contained.d.ts +46 -0
  11. package/dist/contained.js +152 -0
  12. package/dist/context.d.ts +140 -17
  13. package/dist/context.js +135 -37
  14. package/dist/crc32.d.ts +8 -2
  15. package/dist/crc32.js +44 -4
  16. package/dist/engine.d.ts +12 -1
  17. package/dist/engine.js +336 -42
  18. package/dist/errors.d.ts +17 -0
  19. package/dist/errors.js +27 -1
  20. package/dist/fields.d.ts +36 -0
  21. package/dist/fields.js +139 -0
  22. package/dist/findings.d.ts +2 -1
  23. package/dist/findings.js +44 -9
  24. package/dist/flat.d.ts +33 -0
  25. package/dist/flat.js +486 -0
  26. package/dist/formats.d.ts +7 -0
  27. package/dist/formats.js +5 -1
  28. package/dist/formulas.d.ts +27 -0
  29. package/dist/formulas.js +157 -1
  30. package/dist/index.d.ts +3 -2
  31. package/dist/index.js +8 -1
  32. package/dist/index.mjs +1 -1
  33. package/dist/io.d.ts +62 -1
  34. package/dist/io.js +245 -1
  35. package/dist/links.d.ts +27 -0
  36. package/dist/links.js +106 -1
  37. package/dist/msg.d.ts +8 -0
  38. package/dist/msg.js +251 -0
  39. package/dist/objects.d.ts +45 -0
  40. package/dist/objects.js +204 -0
  41. package/dist/odf.d.ts +66 -2
  42. package/dist/odf.js +181 -64
  43. package/dist/offcrypto.d.ts +54 -0
  44. package/dist/offcrypto.js +647 -0
  45. package/dist/ole.d.ts +12 -0
  46. package/dist/ole.js +139 -0
  47. package/dist/oleobj.d.ts +85 -0
  48. package/dist/oleobj.js +215 -0
  49. package/dist/ooxml.d.ts +83 -2
  50. package/dist/ooxml.js +358 -145
  51. package/dist/package.d.ts +3 -0
  52. package/dist/package.js +21 -0
  53. package/dist/plan.d.ts +104 -0
  54. package/dist/plan.js +73 -0
  55. package/dist/plugins.d.ts +8 -0
  56. package/dist/plugins.js +60 -0
  57. package/dist/ppt.d.ts +8 -0
  58. package/dist/ppt.js +281 -0
  59. package/dist/rewrite-cfb.d.ts +14 -0
  60. package/dist/rewrite-cfb.js +52 -0
  61. package/dist/rewrite-rtf.d.ts +9 -0
  62. package/dist/rewrite-rtf.js +45 -0
  63. package/dist/rewrite-xml.d.ts +36 -0
  64. package/dist/rewrite-xml.js +341 -0
  65. package/dist/rewrite-zip.d.ts +8 -0
  66. package/dist/rewrite-zip.js +498 -0
  67. package/dist/rtf.d.ts +8 -0
  68. package/dist/rtf.js +712 -0
  69. package/dist/rtfcomp.d.ts +7 -0
  70. package/dist/rtfcomp.js +80 -0
  71. package/dist/score.js +1 -1
  72. package/dist/sniff.d.ts +5 -0
  73. package/dist/sniff.js +38 -0
  74. package/dist/types.d.ts +66 -2
  75. package/dist/types.js +33 -0
  76. package/dist/vsd.d.ts +13 -0
  77. package/dist/vsd.js +689 -0
  78. package/dist/word.d.ts +9 -0
  79. package/dist/word.js +194 -0
  80. package/dist/xls.d.ts +27 -0
  81. package/dist/xls.js +208 -0
  82. package/dist/xml.d.ts +30 -0
  83. package/dist/xml.js +45 -23
  84. package/dist/zip-writer.d.ts +4 -0
  85. package/dist/zip-writer.js +20 -7
  86. package/dist/zip.d.ts +9 -2
  87. package/dist/zip.js +61 -4
  88. 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 odf_1 = require("./odf");
10
- const ooxml_1 = require("./ooxml");
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 { score, risk } = (0, score_1.scoreFindings)(findings, options);
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
- /** Inspects an Office or OpenDocument file without changing it. The source is closed when the run ends. */
86
- async function inspectOfficeSource(source, options = {}) {
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 = limits.timeMs !== undefined ? Date.now() + limits.timeMs : undefined;
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
- let ctx;
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 zip = await zip_1.ZipArchive.open(src, limits, deadline);
99
- for (const n of zip.notes)
100
- findings.push(factory.make(types_1.OfficeCategory.Container, n.detail, undefined, { bytes: n.bytes }));
101
- ctx = new context_1.RunContext(zip, factory, {
102
- depth: limits.xmlDepth,
103
- tokenSize: limits.xmlTokenSize,
104
- entityDepth: limits.xmlEntityDepth,
105
- entityExpansions: limits.xmlEntityExpansions,
106
- entitySize: limits.xmlEntitySize,
107
- });
108
- const contentTypes = zip.get('[Content_Types].xml');
109
- const odf = zip.entries.some(e => e.name === 'mimetype') || zip.get('META-INF/manifest.xml') !== undefined;
110
- if (contentTypes && odf)
111
- throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.AmbiguousFormat, 'The file has both [Content_Types].xml and an OpenDocument mimetype or manifest', { reason: 'OOXML and OpenDocument markers' });
112
- if (contentTypes)
113
- await (0, ooxml_1.inspectOoxml)(ctx, contentTypes);
114
- else if (odf)
115
- await (0, odf_1.inspectOdf)(ctx);
116
- else
117
- throw new errors_1.StopError(types_1.OfficeCategory.Package, types_1.OfficeDetail.Unrecognized, 'The archive has neither [Content_Types].xml nor an OpenDocument mimetype or manifest', { reason: 'a plain ZIP archive' });
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
- return finish(findings, options, false, ctx.format);
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 (ctx)
125
- findings.push(...ctx.findings);
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?.code === 'DEFUSE_LIMIT') {
130
- // The family's time limit, from this package or any other.
131
- findings.push(factory.make(types_1.OfficeCategory.Limit, types_1.OfficeDetail.Time));
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?.format);
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 source.close?.();
298
+ await sink.abort?.(e);
142
299
  }
143
300
  catch {
144
- // The verdict stands whether or not the source closes.
301
+ /* the copy's error is the one to report */
145
302
  }
303
+ throw e;
146
304
  }
305
+ await sink.close();
147
306
  }
148
- /** Inspects Office or OpenDocument bytes. */
149
- async function inspectOffice(bytes, options = {}) {
150
- return inspectOfficeSource((0, io_1.bufferSource)(bytes), options);
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.
@@ -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
+ }