@patrickdk77/archive-defuse 0.0.0-stage → 0.1.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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -2
  3. package/dist/aes7z.d.ts +16 -0
  4. package/dist/aes7z.js +131 -0
  5. package/dist/alone.d.ts +21 -0
  6. package/dist/alone.js +65 -0
  7. package/dist/bcj2.d.ts +21 -0
  8. package/dist/bcj2.js +140 -0
  9. package/dist/bzip2.d.ts +21 -0
  10. package/dist/bzip2.js +688 -0
  11. package/dist/cab.d.ts +63 -0
  12. package/dist/cab.js +493 -0
  13. package/dist/checks.d.ts +23 -0
  14. package/dist/checks.js +123 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.js +364 -0
  17. package/dist/codecs.d.ts +39 -0
  18. package/dist/codecs.js +124 -0
  19. package/dist/coders.d.ts +58 -0
  20. package/dist/coders.js +392 -0
  21. package/dist/crc32.d.ts +8 -0
  22. package/dist/crc32.js +59 -0
  23. package/dist/detect.d.ts +10 -0
  24. package/dist/detect.js +95 -0
  25. package/dist/encoder.d.ts +44 -0
  26. package/dist/encoder.js +114 -0
  27. package/dist/engine.d.ts +44 -0
  28. package/dist/engine.js +753 -0
  29. package/dist/errors.d.ts +64 -0
  30. package/dist/errors.js +98 -0
  31. package/dist/filters.d.ts +34 -0
  32. package/dist/filters.js +385 -0
  33. package/dist/findings.d.ts +26 -0
  34. package/dist/findings.js +176 -0
  35. package/dist/formats.d.ts +24 -0
  36. package/dist/formats.js +106 -0
  37. package/dist/index.d.ts +9 -0
  38. package/dist/index.js +53 -0
  39. package/dist/index.mjs +3 -0
  40. package/dist/inflate64.d.ts +6 -0
  41. package/dist/inflate64.js +388 -0
  42. package/dist/io.d.ts +78 -0
  43. package/dist/io.js +379 -0
  44. package/dist/lzma-formats.d.ts +75 -0
  45. package/dist/lzma-formats.js +373 -0
  46. package/dist/lzma-sniff.d.ts +20 -0
  47. package/dist/lzma-sniff.js +73 -0
  48. package/dist/lzma-writers.d.ts +35 -0
  49. package/dist/lzma-writers.js +276 -0
  50. package/dist/lzma1/LICENSE +201 -0
  51. package/dist/lzma1/NOTICE +36 -0
  52. package/dist/lzma1/crc32.d.ts +4 -0
  53. package/dist/lzma1/crc32.js +19 -0
  54. package/dist/lzma1/hash234.d.ts +22 -0
  55. package/dist/lzma1/hash234.js +75 -0
  56. package/dist/lzma1/hc4.d.ts +30 -0
  57. package/dist/lzma1/hc4.js +142 -0
  58. package/dist/lzma1/header.d.ts +32 -0
  59. package/dist/lzma1/header.js +73 -0
  60. package/dist/lzma1/lz-decoder.d.ts +41 -0
  61. package/dist/lzma1/lz-decoder.js +128 -0
  62. package/dist/lzma1/lz-encoder.d.ts +87 -0
  63. package/dist/lzma1/lz-encoder.js +166 -0
  64. package/dist/lzma1/lzma-coder.d.ts +107 -0
  65. package/dist/lzma1/lzma-coder.js +162 -0
  66. package/dist/lzma1/lzma-decoder.d.ts +36 -0
  67. package/dist/lzma1/lzma-decoder.js +484 -0
  68. package/dist/lzma1/lzma-encoder-fast.d.ts +13 -0
  69. package/dist/lzma1/lzma-encoder-fast.js +114 -0
  70. package/dist/lzma1/lzma-encoder.d.ts +105 -0
  71. package/dist/lzma1/lzma-encoder.js +497 -0
  72. package/dist/lzma1/lzma2-decoder.d.ts +85 -0
  73. package/dist/lzma1/lzma2-decoder.js +249 -0
  74. package/dist/lzma1/range-coder.d.ts +18 -0
  75. package/dist/lzma1/range-coder.js +23 -0
  76. package/dist/lzma1/range-decoder.d.ts +41 -0
  77. package/dist/lzma1/range-decoder.js +78 -0
  78. package/dist/lzma1/range-encoder.d.ts +53 -0
  79. package/dist/lzma1/range-encoder.js +269 -0
  80. package/dist/lzma1-stream.d.ts +60 -0
  81. package/dist/lzma1-stream.js +202 -0
  82. package/dist/model.d.ts +141 -0
  83. package/dist/model.js +16 -0
  84. package/dist/names.d.ts +13 -0
  85. package/dist/names.js +66 -0
  86. package/dist/pack.d.ts +79 -0
  87. package/dist/pack.js +590 -0
  88. package/dist/plugins.d.ts +13 -0
  89. package/dist/plugins.js +56 -0
  90. package/dist/rules.d.ts +25 -0
  91. package/dist/rules.js +403 -0
  92. package/dist/score.d.ts +17 -0
  93. package/dist/score.js +58 -0
  94. package/dist/seven.d.ts +75 -0
  95. package/dist/seven.js +492 -0
  96. package/dist/sevenzip.d.ts +133 -0
  97. package/dist/sevenzip.js +529 -0
  98. package/dist/single.d.ts +103 -0
  99. package/dist/single.js +453 -0
  100. package/dist/sniff.d.ts +20 -0
  101. package/dist/sniff.js +224 -0
  102. package/dist/stream-meter.d.ts +39 -0
  103. package/dist/stream-meter.js +54 -0
  104. package/dist/tar.d.ts +102 -0
  105. package/dist/tar.js +765 -0
  106. package/dist/types.d.ts +311 -0
  107. package/dist/types.js +133 -0
  108. package/dist/unpack.d.ts +67 -0
  109. package/dist/unpack.js +194 -0
  110. package/dist/xz.d.ts +25 -0
  111. package/dist/xz.js +333 -0
  112. package/dist/zip-lzma.d.ts +15 -0
  113. package/dist/zip-lzma.js +137 -0
  114. package/dist/zip-writer.d.ts +51 -0
  115. package/dist/zip-writer.js +305 -0
  116. package/dist/zip.d.ts +99 -0
  117. package/dist/zip.js +901 -0
  118. package/dist/zipcrypt.d.ts +47 -0
  119. package/dist/zipcrypt.js +233 -0
  120. package/dist/zstd.d.ts +25 -0
  121. package/dist/zstd.js +160 -0
  122. package/package.json +94 -4
package/dist/engine.js ADDED
@@ -0,0 +1,753 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.limitsOf = exports.passwordsOf = exports.RUN_OPTIONS = exports.DEFAULT_LIMITS = void 0;
4
+ exports.makeMeter = makeMeter;
5
+ exports.inspectArchiveSource = inspectArchiveSource;
6
+ exports.disarmArchiveSource = disarmArchiveSource;
7
+ exports.disarmAtDepth = disarmAtDepth;
8
+ exports.inspectArchive = inspectArchive;
9
+ exports.disarmArchive = disarmArchive;
10
+ const crc32_1 = require("./crc32");
11
+ const errors_1 = require("./errors");
12
+ const findings_1 = require("./findings");
13
+ const formats_1 = require("./formats");
14
+ const io_1 = require("./io");
15
+ const rules_1 = require("./rules");
16
+ const score_1 = require("./score");
17
+ const sniff_1 = require("./sniff");
18
+ const types_1 = require("./types");
19
+ const zip_1 = require("./zip");
20
+ exports.DEFAULT_LIMITS = {
21
+ entries: 10_000,
22
+ decompressedBytes: 512 * 1024 * 1024,
23
+ totalDecompressedBytes: 2 * 1024 * 1024 * 1024,
24
+ compressionRatio: 1000,
25
+ nestingDepth: 10,
26
+ dictionaryBytes: 256 * 1024 * 1024,
27
+ };
28
+ /** An entry's ratio is checked once it passes this many decoded bytes. */
29
+ const RATIO_FLOOR = 1024 * 1024;
30
+ /** The key under which archive-defuse hands its run's options to a plugin. No other package can name it. */
31
+ exports.RUN_OPTIONS = Symbol('archive-defuse run options');
32
+ async function endRun(run) {
33
+ try {
34
+ for (const s of run.held)
35
+ await s.dispose();
36
+ }
37
+ finally {
38
+ await run.temp.cleanup();
39
+ }
40
+ }
41
+ const PLUGIN_KEPT = new Set([types_1.ArchiveDetail.PluginPassed, types_1.ArchiveDetail.PluginScrubbed]);
42
+ /** Kinds whose content the rewrite drops whatever their action, so an info override leaves CONTENT_REMOVED. */
43
+ const DROPPED_ANYWAY = new Set([types_1.ArchiveDetail.HiddenData, types_1.ArchiveDetail.NonFileData, types_1.ArchiveDetail.ExtraField]);
44
+ /**
45
+ * `incomplete` marks a run that could not inspect the whole archive. finish() then rejects it without a score,
46
+ * whatever the actions say, because an override can make the finding that stopped it look harmless.
47
+ */
48
+ function finish(findings, options, incomplete, archive, isOutput = false) {
49
+ const named = archive ? { format: archive.format, entries: archive.entries.length } : {};
50
+ if (incomplete)
51
+ return { status: 'rejected', score: null, risk: types_1.ArchiveRisk.Unknown, findings, ...named };
52
+ const rejected = findings.some(f => f.action === 'reject');
53
+ // In an upload, a plugin rewrite or a metadata strip changes the file, so the output must be written.
54
+ const strippable = findings.some(f => f.action === 'strip' || (!isOutput && (f.detail === types_1.ArchiveDetail.PluginScrubbed || f.detail === types_1.ArchiveDetail.Stripped) && f.attachment === undefined));
55
+ const { score, risk } = (0, score_1.scoreFindings)(isOutput ? findings : findings.filter(f => !PLUGIN_KEPT.has(f.detail)), options);
56
+ return { status: rejected ? 'rejected' : strippable ? 'strippable' : 'clean', score, risk, findings, ...named };
57
+ }
58
+ /** A value from the archive, such as a name, cut to a length a report can carry. */
59
+ const clip = (s) => (s.length > 4096 ? `${s.slice(0, 4093)}...` : s);
60
+ const clipped = (data) => Object.fromEntries(Object.entries(data).map(([k, v]) => [k, typeof v === 'string' ? clip(v) : v]));
61
+ /** Collects findings. Past 200 of one kind, further ones only raise a count, as in pdf-defuse. */
62
+ class Findings {
63
+ factory;
64
+ list = [];
65
+ perKey = new Map();
66
+ aggregates = new Map();
67
+ constructor(factory) {
68
+ this.factory = factory;
69
+ }
70
+ push(f) {
71
+ const k = `${f.category}/${f.detail}`;
72
+ const n = (this.perKey.get(k) ?? 0) + 1;
73
+ this.perKey.set(k, n);
74
+ if (n <= 200) {
75
+ this.list.push(f);
76
+ return;
77
+ }
78
+ let agg = this.aggregates.get(k);
79
+ if (!agg) {
80
+ const data = { ...f.data, count: 0 };
81
+ agg = (0, findings_1.findingSpec)(f.category, f.detail)
82
+ ? this.factory.make(f.category, f.detail, 'additional occurrences', data)
83
+ : { category: f.category, detail: f.detail, description: f.description, action: f.action, location: 'additional occurrences', data, weight: f.weight };
84
+ agg.action = f.action;
85
+ this.aggregates.set(k, agg);
86
+ this.list.push(agg);
87
+ }
88
+ else if (f.action === 'reject' || (f.action === 'strip' && agg.action === 'info'))
89
+ agg.action = f.action;
90
+ const data = agg.data;
91
+ if (data)
92
+ data.count = Number(data.count) + 1;
93
+ }
94
+ emit(category, detail, location, data) {
95
+ const f = this.factory.make(category, detail, location && clip(location), data && clipped(data));
96
+ this.push(f);
97
+ return f;
98
+ }
99
+ /** Reports content that goes whatever the overrides say. Its action is strip, or reject when the caller asked for that. */
100
+ removal(category, detail, location, data) {
101
+ const f = this.factory.make(category, detail, location, data);
102
+ if (f.action === 'info')
103
+ f.action = 'strip';
104
+ this.push(f);
105
+ return f;
106
+ }
107
+ has(category, detail, where) {
108
+ return this.list.some(f => f.category === category && f.detail === detail && (where === undefined || f.data?.where === where));
109
+ }
110
+ }
111
+ /** Where an entry sits, for findings. */
112
+ const locate = (e, archive) => (e.name === '' && archive.single ? 'compressed content' : e.name === '' && archive.nameless ? `entry ${e.index + 1}` : `entry "${e.name}"`);
113
+ /**
114
+ * Throws on an error a plugin threw that stops the whole tree. An I/O error fails this run as one of its own would,
115
+ * so its cause goes up.
116
+ */
117
+ function passOn(run, e) {
118
+ const code = (0, errors_1.stopsTree)(e);
119
+ if (code === 'DEFUSE_IO') {
120
+ const cause = e.cause ?? e;
121
+ run.temp.ioError ??= cause;
122
+ throw cause;
123
+ }
124
+ if (code === 'DEFUSE_LIMIT')
125
+ throw e;
126
+ }
127
+ /** Adds findings a plugin reported from inside an entry. A reject inside it means the entry goes, not the archive, so it is recorded as strip. */
128
+ function pushContained(out, found, location, name) {
129
+ const label = name || 'unnamed file';
130
+ for (const f of found ?? []) {
131
+ out.push({
132
+ ...f,
133
+ action: f.action === 'reject' ? 'strip' : f.action,
134
+ location: f.location ? `${location} > ${f.location}` : location,
135
+ data: f.data && { ...f.data },
136
+ attachment: f.attachment ? `${label} > ${f.attachment}` : label,
137
+ });
138
+ }
139
+ }
140
+ /** The passwords to try on an encrypted entry, in order, with the empty password last. */
141
+ const passwordsOf = (options) => [...(Array.isArray(options.password) ? options.password : options.password === undefined ? [] : [options.password]), ''];
142
+ exports.passwordsOf = passwordsOf;
143
+ /** The limits of a run, with the defaults filled in. */
144
+ const limitsOf = (options) => ({ ...exports.DEFAULT_LIMITS, ...Object.fromEntries(Object.entries(options.limits ?? {}).filter(([, v]) => v !== undefined)) });
145
+ exports.limitsOf = limitsOf;
146
+ /** Where a stop happened: an entry by its name, or a 7z folder or header as the 7z reader names it. */
147
+ const stopLocation = (location) => (location === undefined ? undefined : SEVEN_PLACE.test(location) ? location : `entry "${location}"`);
148
+ const SEVEN_PLACE = /^(folder [1-9][0-9]*|header)$/;
149
+ /** The finding for a limit or a refusal that stopped the run. */
150
+ function stopFinding(factory, e) {
151
+ if (e instanceof errors_1.StopError)
152
+ return factory.make(e.category, e.detail, stopLocation(e.location), e.data);
153
+ if ((0, errors_1.stopsTree)(e) === 'DEFUSE_LIMIT')
154
+ return factory.make(types_1.ArchiveCategory.Limit, e.limit === 'nesting' ? types_1.ArchiveDetail.NestingDepth : types_1.ArchiveDetail.Time);
155
+ // What the xz, lzma and 7z readers throw while they open a file.
156
+ if (e instanceof errors_1.CheckError)
157
+ return factory.make(types_1.ArchiveCategory.Corrupted, types_1.ArchiveDetail.CheckFailed, undefined, { check: e.check });
158
+ if (e instanceof errors_1.DataError && e.truncated)
159
+ return factory.make(types_1.ArchiveCategory.Corrupted, types_1.ArchiveDetail.Truncated, undefined, { what: 'structure' });
160
+ if (e instanceof errors_1.ParseError)
161
+ return factory.make(types_1.ArchiveCategory.Corrupted, types_1.ArchiveDetail.Unparseable, undefined, { reason: e.message.slice(0, 120) });
162
+ if (e instanceof errors_1.UnsupportedError)
163
+ return unsupported(factory, e);
164
+ return undefined;
165
+ }
166
+ /** The finding for a method or a feature of the format this package does not read. */
167
+ function unsupported(factory, e, location) {
168
+ if (!e.method)
169
+ return factory.make(types_1.ArchiveCategory.Compression, types_1.ArchiveDetail.UnsupportedFeature, location, { reason: String(e.data?.reason ?? e.message).slice(0, 120) });
170
+ return factory.make(types_1.ArchiveCategory.Compression, types_1.ArchiveDetail.UnsupportedMethod, location, { method: String(e.data?.method ?? e.message).slice(0, 120) });
171
+ }
172
+ /**
173
+ * Inspects `source` and leaves what it opens in `run`. It throws an I/O error instead of reporting a verdict on the
174
+ * archive, and a time or nesting limit for the caller to pass up or report.
175
+ */
176
+ async function analyze(run, source, s) {
177
+ try {
178
+ return await analyzeInner(run, source, s);
179
+ }
180
+ catch (e) {
181
+ if (run.temp.ioError !== undefined)
182
+ throw run.temp.ioError;
183
+ if ((0, errors_1.stopsTree)(e) || (0, errors_1.isIoError)(e))
184
+ throw e;
185
+ const factory = new findings_1.FindingFactory(s.options.actionOverrides);
186
+ const f = stopFinding(factory, e) ?? factory.make(types_1.ArchiveCategory.Corrupted, types_1.ArchiveDetail.Unparseable, undefined, { reason: String(e?.message ?? e).slice(0, 120) });
187
+ return finish([f], s.options, true, run.archive);
188
+ }
189
+ }
190
+ async function analyzeInner(run, source, s) {
191
+ const { options, depth, deadline } = s;
192
+ const factory = new findings_1.FindingFactory(options.actionOverrides);
193
+ const out = new Findings(factory);
194
+ const limits = (0, exports.limitsOf)(options);
195
+ if (limits.nestingDepth !== undefined && depth > limits.nestingDepth)
196
+ throw new errors_1.NestingLimitError('Nesting limit exceeded');
197
+ const size = await source.size();
198
+ if (limits.fileSize !== undefined && size > limits.fileSize)
199
+ return finish([factory.make(types_1.ArchiveCategory.Limit, types_1.ArchiveDetail.FileSize, undefined, { size })], options, true);
200
+ let archive;
201
+ try {
202
+ archive = await (0, formats_1.openArchive)(source, limits, deadline, (0, exports.passwordsOf)(options), { dir: run.temp, threshold: options.memoryThreshold ?? 8 * 1024 * 1024 });
203
+ }
204
+ catch (e) {
205
+ // A time limit in a nested run stops the whole tree, so it goes up to the container.
206
+ if (depth > 0 && (0, errors_1.stopsTree)(e) === 'DEFUSE_LIMIT')
207
+ throw e;
208
+ const f = stopFinding(factory, e);
209
+ if (!f)
210
+ throw e;
211
+ return finish([f], options, true);
212
+ }
213
+ run.archive = archive;
214
+ let incomplete = false;
215
+ const stop = (f) => {
216
+ out.push(f);
217
+ incomplete = true;
218
+ };
219
+ // Findings whose content the writer keeps when they are info, which therefore remove nothing.
220
+ const keptOnInfo = new Set();
221
+ const note = (n, location) => {
222
+ const f = out.emit(n.category, n.detail, location, n.data);
223
+ if (n.keptOnInfo)
224
+ keptOnInfo.add(f);
225
+ };
226
+ for (const n of archive.notes)
227
+ note(n, n.location ?? n.data?.where);
228
+ if (archive.comment !== undefined)
229
+ out.emit(types_1.ArchiveCategory.Metadata, types_1.ArchiveDetail.Comment, 'archive');
230
+ const totals = { decoded: 0 };
231
+ const meterFor = (e) => makeMeter(e, limits, deadline, totals);
232
+ let decisions = [];
233
+ const kept = [];
234
+ try {
235
+ // Symbolic links in a ZIP keep their targets as content, which the link rules need.
236
+ await readLinkTargets(archive, meterFor);
237
+ decisions = (0, rules_1.applyRules)(archive.entries, factory, (c, d, e, data) => out.emit(c, d, locate(e, archive), data), {
238
+ names: !(archive.nameless || (archive.single && archive.entries[0]?.name === '')),
239
+ backslashIsSeparator: archive.format !== 'cab',
240
+ });
241
+ const passwords = (0, exports.passwordsOf)(options);
242
+ const threshold = options.memoryThreshold ?? 8 * 1024 * 1024;
243
+ let retained = 0;
244
+ for (const [i, e] of archive.entries.entries()) {
245
+ if (i % 64 === 0 && deadline !== undefined && Date.now() > deadline)
246
+ throw new errors_1.TimeLimitError('Time limit exceeded');
247
+ const d = decisions[i];
248
+ const loc = locate(e, archive);
249
+ for (const n of e.notes)
250
+ note(n, n.location ?? loc);
251
+ if (e.comment !== undefined)
252
+ out.emit(types_1.ArchiveCategory.Metadata, types_1.ArchiveDetail.Comment, loc);
253
+ const enc = e.encryption;
254
+ if (enc?.kind === 'zipcrypto')
255
+ out.emit(types_1.ArchiveCategory.Encrypted, types_1.ArchiveDetail.ZipCrypto, loc);
256
+ else if (enc?.kind === 'aes')
257
+ out.emit(types_1.ArchiveCategory.Encrypted, enc.bits === 128 ? types_1.ArchiveDetail.Aes128 : enc.bits === 192 ? types_1.ArchiveDetail.Aes192 : types_1.ArchiveDetail.Aes256, loc);
258
+ if (d.remove)
259
+ continue;
260
+ const hasContent = e.kind === 'file' || e.kind === 'other';
261
+ const hasData = hasContent || e.kind === 'symlink';
262
+ if (hasData && e.method && !e.method.readable && (e.size > 0 || e.compressedSize > 0)) {
263
+ stop(factory.make(types_1.ArchiveCategory.Compression, types_1.ArchiveDetail.UnsupportedMethod, loc, { method: e.method.name }));
264
+ continue;
265
+ }
266
+ if (hasData && e.method?.unavailable) {
267
+ stop(factory.make(types_1.ArchiveCategory.Compression, types_1.ArchiveDetail.Unavailable, loc, { method: e.method.name }));
268
+ continue;
269
+ }
270
+ let recompress = false;
271
+ if (hasData && e.method?.unusual)
272
+ recompress = out.emit(types_1.ArchiveCategory.Compression, types_1.ArchiveDetail.UnusualMethod, loc, { method: e.method.name }).action === 'strip';
273
+ if (enc?.kind === 'unsupported') {
274
+ stop(factory.make(types_1.ArchiveCategory.Encrypted, types_1.ArchiveDetail.Unsupported, loc, { reason: enc.reason }));
275
+ continue;
276
+ }
277
+ if (e.broken) {
278
+ out.removal(types_1.ArchiveCategory.Corrupted, types_1.ArchiveDetail.EntryData, loc, { name: e.name, reason: e.broken });
279
+ continue;
280
+ }
281
+ const k = { entry: e, name: d.name, mode: d.mode, recompress, dropCapabilities: d.dropCapabilities };
282
+ // The passwords are tried in order, each only once it opens the entry. A ZipCrypto header can pass for a wrong
283
+ // password, so a password whose data then fails gives way to the next one that opens the entry.
284
+ const tried = new Set();
285
+ const nextPassword = async () => {
286
+ if (!enc) {
287
+ if (tried.size)
288
+ return null;
289
+ tried.add('');
290
+ return undefined;
291
+ }
292
+ for (const p of passwords) {
293
+ if (tried.has(p))
294
+ continue;
295
+ tried.add(p);
296
+ if (await archive.unlock(e, p))
297
+ return p;
298
+ }
299
+ return null;
300
+ };
301
+ let decoded;
302
+ let failure;
303
+ let opened = false;
304
+ for (let password = await nextPassword(); password !== null; password = await nextPassword()) {
305
+ opened = true;
306
+ if (!hasContent) {
307
+ k.password = password;
308
+ break;
309
+ }
310
+ // Read the content, under the limits, into memory or a temporary file.
311
+ decoded = new io_1.SpillSink(run.temp, threshold);
312
+ const meter = meterFor(e);
313
+ let crc = 0;
314
+ try {
315
+ for await (const c of archive.read(e, meter, password)) {
316
+ if (enc?.kind === 'aes')
317
+ crc = (0, crc32_1.crc32)(c, crc);
318
+ await decoded.write(c);
319
+ }
320
+ await decoded.close();
321
+ k.password = password;
322
+ if (enc?.kind === 'aes')
323
+ k.crc = crc;
324
+ failure = undefined;
325
+ break;
326
+ }
327
+ catch (err) {
328
+ await decoded.dispose();
329
+ decoded = undefined;
330
+ totals.decoded -= meter.total;
331
+ if (err instanceof errors_1.DataError) {
332
+ failure = err;
333
+ continue;
334
+ }
335
+ if (err instanceof errors_1.UnsupportedError) {
336
+ stop(unsupported(factory, err, loc));
337
+ break;
338
+ }
339
+ throw err;
340
+ }
341
+ }
342
+ if (!opened) {
343
+ stop(factory.make(types_1.ArchiveCategory.Encrypted, types_1.ArchiveDetail.PasswordRequired, loc));
344
+ continue;
345
+ }
346
+ if (!hasContent) {
347
+ kept.push(k);
348
+ continue;
349
+ }
350
+ if (incomplete && !decoded)
351
+ continue;
352
+ if (!decoded) {
353
+ const reason = failure?.message.slice(0, 120) ?? '';
354
+ if (archive.single) {
355
+ if (failure instanceof errors_1.CheckError)
356
+ stop(factory.make(types_1.ArchiveCategory.Corrupted, types_1.ArchiveDetail.CheckFailed, loc, { check: failure.check }));
357
+ else if (failure instanceof errors_1.ParseError && !failure.truncated)
358
+ stop(factory.make(types_1.ArchiveCategory.Corrupted, types_1.ArchiveDetail.Unparseable, loc, { reason }));
359
+ else
360
+ stop(factory.make(types_1.ArchiveCategory.Corrupted, failure?.truncated ? types_1.ArchiveDetail.Truncated : types_1.ArchiveDetail.DataError, loc, failure?.truncated ? { what: 'compressed stream' } : { reason }));
361
+ continue;
362
+ }
363
+ out.removal(types_1.ArchiveCategory.Corrupted, types_1.ArchiveDetail.EntryData, loc, { name: e.name, reason });
364
+ continue;
365
+ }
366
+ if (enc)
367
+ out.emit(types_1.ArchiveCategory.Encrypted, k.password === '' ? types_1.ArchiveDetail.EmptyPassword : types_1.ArchiveDetail.Decrypted, loc);
368
+ if (decoded.spilled)
369
+ out.emit(types_1.ArchiveCategory.Processing, types_1.ArchiveDetail.MemoryFallback, loc, { part: `Entry "${e.name.slice(0, 60)}"` });
370
+ try {
371
+ const decision = await decideContent(run, s, out, e, loc, decoded, threshold - retained);
372
+ if (!decision.keep)
373
+ continue;
374
+ if (decision.replacement) {
375
+ k.replacement = decision.replacement;
376
+ if (!decision.replacement.spilled)
377
+ retained += decision.replacement.length;
378
+ }
379
+ kept.push(k);
380
+ }
381
+ finally {
382
+ await decoded.dispose();
383
+ }
384
+ }
385
+ for (const n of archive.lateNotes)
386
+ out.emit(n.category, n.detail, n.location ?? n.data?.where, n.data);
387
+ // A compressed file whose one file goes leaves nothing to write, so what removed it rejects the file instead, as
388
+ // file-defuse does with an upload it would have to strip whole.
389
+ if (archive.single && !incomplete && !kept.length && archive.entries.length) {
390
+ const loc = locate(archive.entries[0], archive);
391
+ for (const f of out.list)
392
+ if (f.attachment === undefined && f.location === loc && f.action === 'strip')
393
+ f.action = 'reject';
394
+ }
395
+ }
396
+ catch (e) {
397
+ // A time or nesting limit, its own or one a plugin threw, stops the whole tree: a nested run passes it up.
398
+ if (depth > 0 && (0, errors_1.stopsTree)(e) === 'DEFUSE_LIMIT')
399
+ throw e;
400
+ const f = stopFinding(factory, e);
401
+ if (!f)
402
+ throw e;
403
+ out.push(f);
404
+ return finish(out.list, options, true, archive);
405
+ }
406
+ // Another end record inside an entry's compressed bytes would come through a copy of them, so that entry is
407
+ // compressed afresh.
408
+ if (factory.strips(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.SeveralEndRecords))
409
+ recompressRivals(archive, kept);
410
+ // A hard link goes with the file it links to, and names it by the name the rewrite gives that file.
411
+ const keptIndex = new Map(kept.map(k => [k.entry.index, k]));
412
+ const final = kept.filter(k => {
413
+ if (k.entry.kind !== 'hardlink')
414
+ return true;
415
+ const to = decisions[k.entry.index].linkIndex;
416
+ const target = to === undefined ? undefined : keptIndex.get(to);
417
+ if (to !== undefined && !target)
418
+ return false;
419
+ if (target)
420
+ k.linkTarget = target.name;
421
+ return true;
422
+ });
423
+ if (options.stripMetadata && archive.hasMetadata())
424
+ out.emit(types_1.ArchiveCategory.Metadata, types_1.ArchiveDetail.Stripped);
425
+ if (!incomplete && out.list.some(f => f.attachment === undefined && DROPPED_ANYWAY.has(f.detail) && f.action === 'info' && !keptOnInfo.has(f)))
426
+ out.removal(types_1.ArchiveCategory.Processing, types_1.ArchiveDetail.ContentRemoved);
427
+ const strips = (c, d) => factory.strips(c, d);
428
+ run.plan = {
429
+ kept: final,
430
+ stripMetadata: options.stripMetadata === true,
431
+ keepLeading: !strips(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.LeadingBytes) && !(out.has(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.Polyglot, 'before the archive') && strips(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.Polyglot)),
432
+ keepTrailing: !strips(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.TrailingBytes) &&
433
+ !(out.list.some(f => f.detail === types_1.ArchiveDetail.Polyglot && String(f.data?.where ?? '').startsWith('after')) && strips(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.Polyglot)) &&
434
+ !(archive.format === 'tar' && out.has(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.HiddenData) && strips(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.HiddenData)),
435
+ keepExtraFields: !strips(types_1.ArchiveCategory.Structure, types_1.ArchiveDetail.ExtraField),
436
+ ratioLimit: limits.compressionRatio,
437
+ };
438
+ run.entries = final.length;
439
+ return finish(out.list, options, incomplete, archive, s.verify !== undefined);
440
+ }
441
+ /** Reads the targets of symbolic links, which ZIP and 7z keep as their content. */
442
+ async function readLinkTargets(archive, meterFor) {
443
+ if (!archive.linkTarget)
444
+ return;
445
+ for (const e of archive.entries) {
446
+ if (e.kind !== 'symlink' || e.broken)
447
+ continue;
448
+ try {
449
+ e.linkTarget = await archive.linkTarget(e, meterFor(e));
450
+ }
451
+ catch (err) {
452
+ if (!(err instanceof errors_1.DataError))
453
+ throw err;
454
+ }
455
+ }
456
+ }
457
+ /** Marks for compressing afresh the ZIP entries whose stored bytes hold another end record. */
458
+ function recompressRivals(archive, kept) {
459
+ if (!(archive instanceof zip_1.ZipArchive) || !archive.rivals.length)
460
+ return;
461
+ for (const k of kept) {
462
+ const e = k.entry;
463
+ if (archive.rivals.some(at => at >= e.dataOffset && at < e.dataOffset + e.compressedSize))
464
+ k.recompress = true;
465
+ }
466
+ }
467
+ /** A meter for one entry: its own decoded size, the run's total, the ratio, and the deadline. */
468
+ function makeMeter(e, limits, deadline, totals) {
469
+ const meter = {
470
+ total: 0,
471
+ add(decoded, compressedSoFar) {
472
+ totals.decoded += decoded - meter.total;
473
+ meter.total = decoded;
474
+ if (deadline !== undefined && Date.now() > deadline)
475
+ throw new errors_1.TimeLimitError('Time limit exceeded');
476
+ if (decoded > limits.decompressedBytes)
477
+ throw new errors_1.StopError(types_1.ArchiveCategory.Limit, types_1.ArchiveDetail.DecompressedSize, 'An entry decodes past the limit', { limit: limits.decompressedBytes }, e.name || undefined);
478
+ if (totals.decoded > limits.totalDecompressedBytes)
479
+ throw new errors_1.StopError(types_1.ArchiveCategory.Limit, types_1.ArchiveDetail.TotalDecompressedSize, 'The entries decode past the limit', { limit: limits.totalDecompressedBytes });
480
+ if (decoded > RATIO_FLOOR && compressedSoFar !== undefined && decoded > Math.max(1, compressedSoFar) * limits.compressionRatio) {
481
+ throw new errors_1.StopError(types_1.ArchiveCategory.Limit, types_1.ArchiveDetail.Ratio, 'An entry decodes past the ratio limit', { limit: limits.compressionRatio }, e.name || undefined);
482
+ }
483
+ },
484
+ time() {
485
+ if (deadline !== undefined && Date.now() > deadline)
486
+ throw new errors_1.TimeLimitError('Time limit exceeded');
487
+ },
488
+ };
489
+ return meter;
490
+ }
491
+ /**
492
+ * Shows an entry's content to the file plugins, as pdf-defuse shows an attached file. The first plugin that accepts it
493
+ * decides: 'passed' and 'scrubbed' keep it, with the bytes the plugin wrote when it wrote any, and 'removed', a failure
494
+ * or a scrub that wrote nothing remove it. When no plugin accepts it, ENTRY/NO_PLUGIN decides. In the check of an
495
+ * output the plugins are not asked again: content whose MD5 matches what a plugin kept counts as kept.
496
+ */
497
+ async function decideContent(run, s, out, e, loc, decoded, room) {
498
+ const name = e.name === '' ? undefined : e.name;
499
+ const hash = decoded.digest();
500
+ const expected = s.verify?.get(hash);
501
+ if (expected) {
502
+ out.emit(types_1.ArchiveCategory.Entry, expected.result === 'passed' ? types_1.ArchiveDetail.PluginPassed : types_1.ArchiveDetail.PluginScrubbed, loc, { plugin: expected.plugin, name: name ?? '' });
503
+ pushContained(out, expected.findings, loc, name);
504
+ return { keep: true };
505
+ }
506
+ const source = decoded.source();
507
+ try {
508
+ const head = await source.read(0, Math.min(1024, decoded.length));
509
+ const sniffed = (0, sniff_1.sniffType)(head);
510
+ if ((0, sniff_1.typesDisagree)(undefined, name, sniffed)) {
511
+ const f = out.emit(types_1.ArchiveCategory.Entry, types_1.ArchiveDetail.TypeMismatch, loc, { name: name ?? '', sniffed: sniffed ?? '', nameType: (0, sniff_1.typeFromName)(name) ?? '' });
512
+ if (f.action === 'strip')
513
+ return { keep: false };
514
+ }
515
+ const file = { name, sniffedType: sniffed, size: decoded.length, location: loc, depth: s.depth + 1, source };
516
+ const context = { depth: file.depth, deadline: s.deadline, [exports.RUN_OPTIONS]: s.options };
517
+ for (const plugin of s.verify ? [] : (s.options.filePlugins ?? [])) {
518
+ try {
519
+ if (!(await plugin.accepts(file)))
520
+ continue;
521
+ }
522
+ catch (err) {
523
+ passOn(run, err);
524
+ continue;
525
+ }
526
+ // Copies plugins keep wait for the rewrite, so all of them together stay under one memory threshold.
527
+ const kept = new io_1.SpillSink(run.temp, Math.max(0, room));
528
+ const data = { plugin: plugin.name, name: name ?? '' };
529
+ try {
530
+ const answer = await plugin.process(file, kept, context);
531
+ const r = typeof answer === 'string' ? { result: answer } : (answer ?? {});
532
+ await kept.close();
533
+ let keep = false;
534
+ if (r.result === 'passed') {
535
+ out.emit(types_1.ArchiveCategory.Entry, types_1.ArchiveDetail.PluginPassed, loc, data);
536
+ // Bytes the plugin wrote are the ones kept, such as a document rewritten with nothing removed.
537
+ const passed = { result: 'passed', plugin: plugin.name, findings: r.outputFindings };
538
+ run.kept.set(hash, passed);
539
+ if (kept.length > 0)
540
+ run.kept.set(kept.digest(), passed);
541
+ keep = true;
542
+ }
543
+ else if (r.result === 'scrubbed' && kept.length > 0) {
544
+ out.emit(types_1.ArchiveCategory.Entry, types_1.ArchiveDetail.PluginScrubbed, loc, data);
545
+ run.kept.set(kept.digest(), { result: 'scrubbed', plugin: plugin.name, findings: r.outputFindings });
546
+ keep = true;
547
+ }
548
+ else
549
+ out.removal(types_1.ArchiveCategory.Entry, r.result === 'removed' ? types_1.ArchiveDetail.PluginRemoved : types_1.ArchiveDetail.PluginFailed, loc, data);
550
+ pushContained(out, r.findings, loc, name);
551
+ if (keep && kept.length > 0) {
552
+ run.held.push(kept);
553
+ return { keep, replacement: kept };
554
+ }
555
+ await kept.dispose();
556
+ return { keep };
557
+ }
558
+ catch (err) {
559
+ await kept.dispose();
560
+ passOn(run, err);
561
+ out.removal(types_1.ArchiveCategory.Entry, types_1.ArchiveDetail.PluginFailed, loc, data);
562
+ return { keep: false };
563
+ }
564
+ }
565
+ const f = out.emit(types_1.ArchiveCategory.Entry, types_1.ArchiveDetail.NoPlugin, loc, { name: name ?? '', sniffed: sniffed ?? '' });
566
+ return { keep: f.action !== 'strip' };
567
+ }
568
+ finally {
569
+ await source.close?.();
570
+ }
571
+ }
572
+ async function closeSource(source) {
573
+ try {
574
+ await source.close?.();
575
+ }
576
+ catch {
577
+ /* ignore */
578
+ }
579
+ }
580
+ function rethrowOrReject(e, options) {
581
+ const f = stopFinding(new findings_1.FindingFactory(options.actionOverrides), e);
582
+ if (!f)
583
+ throw e;
584
+ return finish([f], options, true);
585
+ }
586
+ const newRun = (options) => ({ temp: new io_1.TempDir(options.tempDir), held: [], kept: new Map() });
587
+ /** Inspects an archive without changing it. The source is closed when the run ends. */
588
+ async function inspectArchiveSource(source, options = {}) {
589
+ const run = newRun(options);
590
+ const deadline = options.limits?.timeMs !== undefined ? Date.now() + options.limits.timeMs : undefined;
591
+ try {
592
+ return await analyze(run, source, { options, depth: 0, deadline }).catch(e => rethrowOrReject(e, options));
593
+ }
594
+ finally {
595
+ await closeSource(source);
596
+ await endRun(run);
597
+ }
598
+ }
599
+ /** Copies to the caller's sink. On failure it aborts the sink instead of closing it, so a partial copy does not look whole. */
600
+ async function deliver(from, sink) {
601
+ try {
602
+ await (0, io_1.copyToSink)(from, sink);
603
+ }
604
+ catch (e) {
605
+ try {
606
+ await sink.abort?.(e);
607
+ }
608
+ catch {
609
+ /* the copy's error is the one to report */
610
+ }
611
+ throw e;
612
+ }
613
+ await sink.close();
614
+ }
615
+ /**
616
+ * Writes a defused copy of `source` to `sink`. Nothing reaches the sink unless the output passes inspection. The
617
+ * source is closed when the run ends.
618
+ */
619
+ async function disarmArchiveSource(source, sink, options = {}) {
620
+ return disarmAtDepth(source, sink, options, 0);
621
+ }
622
+ /** Internal entry that carries the nesting depth for archives inside other files, and the deadline they share with the upload. */
623
+ async function disarmAtDepth(source, sink, options, depth, deadline = options.limits?.timeMs !== undefined ? Date.now() + options.limits.timeMs : undefined) {
624
+ const run = newRun(options);
625
+ let input = source;
626
+ try {
627
+ let before;
628
+ try {
629
+ // A caller's source can change between reads, so analysis and the rewrite read one snapshot. A nested archive's
630
+ // source already belongs to the container, and analysis rejects a file over the size limit unread.
631
+ const fileSize = options.limits?.fileSize;
632
+ if (depth === 0 && (fileSize === undefined || (await source.size()) <= fileSize)) {
633
+ const snapshot = new io_1.SpillSink(run.temp, options.memoryThreshold ?? 8 * 1024 * 1024);
634
+ try {
635
+ await (0, io_1.copyToSink)(source, snapshot, () => {
636
+ if (deadline !== undefined && Date.now() > deadline)
637
+ throw new errors_1.TimeLimitError('Time limit exceeded');
638
+ });
639
+ }
640
+ finally {
641
+ await snapshot.close();
642
+ }
643
+ input = snapshot.source();
644
+ }
645
+ before = await analyze(run, input, { options, depth, deadline });
646
+ }
647
+ catch (e) {
648
+ if (depth > 0 && (0, errors_1.stopsTree)(e) === 'DEFUSE_LIMIT')
649
+ throw e;
650
+ before = rethrowOrReject(e, options);
651
+ }
652
+ const archive = run.archive;
653
+ const plan = run.plan;
654
+ if (before.status === 'rejected' || !archive || !plan)
655
+ return { status: 'rejected', before, removed: [] };
656
+ const factory = new findings_1.FindingFactory(options.actionOverrides);
657
+ // The rewrite goes to memory up to the threshold and to a temporary file past it, so the many small archives of a
658
+ // nested tree do not each make a file.
659
+ const out = new io_1.SpillSink(run.temp, options.memoryThreshold ?? 8 * 1024 * 1024);
660
+ const limits = (0, exports.limitsOf)(options);
661
+ try {
662
+ // The rewrite decodes the entries again under the same limits for each entry. The total is not counted again,
663
+ // since an entry compressed afresh can be decoded twice.
664
+ await archive.write(plan, out, {
665
+ meter: () => makeMeter({ index: 0, name: '', kind: 'file', size: 0, compressedSize: 0, notes: [] }, { ...limits, totalDecompressedBytes: Number.POSITIVE_INFINITY }, deadline, { decoded: 0 }),
666
+ temp: run.temp,
667
+ threshold: options.memoryThreshold ?? 8 * 1024 * 1024,
668
+ });
669
+ }
670
+ catch (e) {
671
+ await out.close().catch(() => {
672
+ // The write's failure, handled below, is the one that counts.
673
+ });
674
+ if (run.temp.ioError !== undefined)
675
+ throw run.temp.ioError;
676
+ if ((0, errors_1.isIoError)(e))
677
+ throw e;
678
+ if ((0, errors_1.stopsTree)(e) === 'DEFUSE_LIMIT') {
679
+ if (depth > 0)
680
+ throw e;
681
+ const lf = factory.make(types_1.ArchiveCategory.Limit, e.limit === 'nesting' ? types_1.ArchiveDetail.NestingDepth : types_1.ArchiveDetail.Time);
682
+ return { status: 'rejected', before: { ...before, status: 'rejected', score: null, risk: types_1.ArchiveRisk.Unknown, findings: [...before.findings, lf] }, removed: [] };
683
+ }
684
+ before.findings.push(factory.make(types_1.ArchiveCategory.Processing, types_1.ArchiveDetail.VerificationFailed, undefined, { reasons: `write failed: ${String(e?.message ?? e).slice(0, 100)}` }));
685
+ before.status = 'rejected';
686
+ return { status: 'rejected', before, removed: [] };
687
+ }
688
+ await out.close();
689
+ if (run.temp.ioError !== undefined)
690
+ throw run.temp.ioError;
691
+ const verifyOptions = {
692
+ actionOverrides: options.actionOverrides,
693
+ scoreWeights: options.scoreWeights,
694
+ scoreBands: options.scoreBands,
695
+ tempDir: options.tempDir,
696
+ memoryThreshold: options.memoryThreshold,
697
+ limits: { ...options.limits, fileSize: undefined, timeMs: undefined },
698
+ };
699
+ const outSource = out.source();
700
+ try {
701
+ const check = newRun(verifyOptions);
702
+ let after;
703
+ let entries;
704
+ try {
705
+ after = await analyze(check, outSource, { options: verifyOptions, depth, deadline, verify: run.kept });
706
+ entries = check.entries;
707
+ }
708
+ finally {
709
+ await endRun(check);
710
+ }
711
+ const reasons = after.findings.filter(f => f.action !== 'info').map(f => `${f.category}/${f.detail}`);
712
+ // A check that stopped early verified nothing, even when the caller overrode the finding that stopped it to info.
713
+ if (after.status === 'rejected' && !reasons.length)
714
+ reasons.push('the output check stopped early');
715
+ if (entries !== run.entries)
716
+ reasons.push(`entries ${run.entries} -> ${entries}`);
717
+ if (reasons.length) {
718
+ before.findings.push(factory.make(types_1.ArchiveCategory.Processing, types_1.ArchiveDetail.VerificationFailed, undefined, { reasons: [...new Set(reasons)].slice(0, 10).join(', ') }));
719
+ before.status = 'rejected';
720
+ return { status: 'rejected', before, removed: [] };
721
+ }
722
+ await deliver(outSource, sink);
723
+ return { status: before.status === 'clean' ? 'clean' : 'defused', before, after, removed: before.findings.filter(f => f.action === 'strip') };
724
+ }
725
+ finally {
726
+ await closeSource(outSource);
727
+ }
728
+ }
729
+ catch (e) {
730
+ // A nested archive's I/O error fails the upload's run too, instead of counting as a failed plugin.
731
+ if (depth > 0 && ((0, errors_1.isIoError)(e) || (e !== undefined && e === run.temp.ioError)))
732
+ throw new errors_1.NestedIoError(e);
733
+ throw e;
734
+ }
735
+ finally {
736
+ if (input !== source)
737
+ await closeSource(input);
738
+ // A nested archive's source belongs to the container that found it.
739
+ if (depth === 0)
740
+ await closeSource(source);
741
+ await endRun(run);
742
+ }
743
+ }
744
+ /** Inspects archive bytes. */
745
+ async function inspectArchive(bytes, options = {}) {
746
+ return inspectArchiveSource((0, io_1.bufferSource)(bytes), options);
747
+ }
748
+ /** Defuses archive bytes. `bytes` holds the output unless the archive was rejected. */
749
+ async function disarmArchive(bytes, options = {}) {
750
+ const sink = (0, io_1.bufferSink)();
751
+ const result = await disarmArchiveSource((0, io_1.bufferSource)(bytes), sink, options);
752
+ return result.status === 'rejected' ? result : { ...result, bytes: sink.result() };
753
+ }