@patrickdk77/pdf-defuse 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +390 -0
- package/dist/crypto.d.ts +51 -0
- package/dist/crypto.js +404 -0
- package/dist/data-plugins.d.ts +28 -0
- package/dist/data-plugins.js +525 -0
- package/dist/document.d.ts +191 -0
- package/dist/document.js +1403 -0
- package/dist/engine.d.ts +16 -0
- package/dist/engine.js +392 -0
- package/dist/filters.d.ts +27 -0
- package/dist/filters.js +616 -0
- package/dist/findings.d.ts +24 -0
- package/dist/findings.js +183 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +42 -0
- package/dist/index.mjs +3 -0
- package/dist/io.d.ts +81 -0
- package/dist/io.js +414 -0
- package/dist/objects.d.ts +52 -0
- package/dist/objects.js +178 -0
- package/dist/parser.d.ts +70 -0
- package/dist/parser.js +534 -0
- package/dist/plugins.d.ts +13 -0
- package/dist/plugins.js +56 -0
- package/dist/score.d.ts +17 -0
- package/dist/score.js +63 -0
- package/dist/sniff.d.ts +15 -0
- package/dist/sniff.js +118 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +143 -0
- package/dist/uri.d.ts +29 -0
- package/dist/uri.js +150 -0
- package/dist/walker.d.ts +302 -0
- package/dist/walker.js +2914 -0
- package/dist/writer.d.ts +6 -0
- package/dist/writer.js +112 -0
- package/package.json +86 -4
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ByteSink, type ByteSource, type PdfDisarmResult, type PdfInspection, type PdfOptions } from './types';
|
|
2
|
+
/** Inspects a PDF without changing it. The source is closed when the run ends. */
|
|
3
|
+
export declare function inspectPdfSource(source: ByteSource, options?: PdfOptions): Promise<PdfInspection>;
|
|
4
|
+
/**
|
|
5
|
+
* Writes a defused copy of `source` to `sink`. Nothing reaches the sink unless the output passes inspection. The
|
|
6
|
+
* source is closed when the run ends.
|
|
7
|
+
*/
|
|
8
|
+
export declare function disarmPdfSource(source: ByteSource, sink: ByteSink, options?: PdfOptions): Promise<PdfDisarmResult>;
|
|
9
|
+
/** Internal entry that carries the nesting depth for attached PDFs, and the deadline they share with the upload. */
|
|
10
|
+
export declare function disarmAtDepth(source: ByteSource, sink: ByteSink, options: PdfOptions, depth: number, deadline?: number | undefined): Promise<PdfDisarmResult>;
|
|
11
|
+
/** Inspects PDF bytes. */
|
|
12
|
+
export declare function inspectPdf(bytes: Uint8Array, options?: PdfOptions): Promise<PdfInspection>;
|
|
13
|
+
/** Defuses PDF bytes. `bytes` holds the output unless the file was rejected. */
|
|
14
|
+
export declare function disarmPdf(bytes: Uint8Array, options?: PdfOptions): Promise<PdfDisarmResult & {
|
|
15
|
+
bytes?: Uint8Array;
|
|
16
|
+
}>;
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.inspectPdfSource = inspectPdfSource;
|
|
4
|
+
exports.disarmPdfSource = disarmPdfSource;
|
|
5
|
+
exports.disarmAtDepth = disarmAtDepth;
|
|
6
|
+
exports.inspectPdf = inspectPdf;
|
|
7
|
+
exports.disarmPdf = disarmPdf;
|
|
8
|
+
const document_1 = require("./document");
|
|
9
|
+
const filters_1 = require("./filters");
|
|
10
|
+
const findings_1 = require("./findings");
|
|
11
|
+
const io_1 = require("./io");
|
|
12
|
+
const score_1 = require("./score");
|
|
13
|
+
const types_1 = require("./types");
|
|
14
|
+
const walker_1 = require("./walker");
|
|
15
|
+
async function endRun(run) {
|
|
16
|
+
try {
|
|
17
|
+
await run.doc?.release();
|
|
18
|
+
await run.walker?.cleanup();
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await run.temp.cleanup();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** An error from the operating system, such as EIO or ENOSPC, which says nothing about the PDF. */
|
|
25
|
+
const isIoError = (e) => typeof e?.code === 'string' && /^E[A-Z]+$/.test(e.code);
|
|
26
|
+
function limitFinding(factory, e) {
|
|
27
|
+
if (e instanceof filters_1.DecompressionLimitError)
|
|
28
|
+
return factory.make(types_1.PdfCategory.Limit, types_1.PdfDetail.DecompressedSize);
|
|
29
|
+
if (e instanceof document_1.ObjectLimitError)
|
|
30
|
+
return factory.make(types_1.PdfCategory.Limit, types_1.PdfDetail.ObjectCount);
|
|
31
|
+
// A time or nesting limit from any package goes by its code.
|
|
32
|
+
if ((0, walker_1.stopsTree)(e) === 'DEFUSE_LIMIT')
|
|
33
|
+
return factory.make(types_1.PdfCategory.Limit, e.limit === 'nesting' ? types_1.PdfDetail.NestingDepth : types_1.PdfDetail.Time);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
function encryptionFindings(doc, factory) {
|
|
37
|
+
const r = doc.securityResult;
|
|
38
|
+
if (!r)
|
|
39
|
+
return [];
|
|
40
|
+
const out = [];
|
|
41
|
+
if (r.status === 'ok') {
|
|
42
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, r.password === 'empty' ? types_1.PdfDetail.EmptyPassword : r.password === 'owner' ? types_1.PdfDetail.OwnerPassword : types_1.PdfDetail.UserPassword));
|
|
43
|
+
const bits = r.handler.keyBits;
|
|
44
|
+
const method = r.handler.streamMethod === 'Identity' ? r.handler.stringMethod : r.handler.streamMethod;
|
|
45
|
+
if (method === 'AES256')
|
|
46
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.Aes256));
|
|
47
|
+
else if (method === 'AES128')
|
|
48
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.Aes128));
|
|
49
|
+
else if (method === 'RC4')
|
|
50
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, bits === 40 ? types_1.PdfDetail.Rc4_40 : bits === 128 ? types_1.PdfDetail.Rc4_128 : types_1.PdfDetail.Rc4Other, undefined, bits === 40 || bits === 128 ? undefined : { bits }));
|
|
51
|
+
if (!r.handler.encryptMetadata)
|
|
52
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.MetadataUnencrypted));
|
|
53
|
+
}
|
|
54
|
+
else if (r.status === 'password-required')
|
|
55
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.PasswordRequired));
|
|
56
|
+
else if (r.status === 'certificate-handler')
|
|
57
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.CertificateHandler));
|
|
58
|
+
else if (r.status === 'unknown-handler')
|
|
59
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.UnknownHandler, undefined, r.detail ? { handler: r.detail } : undefined));
|
|
60
|
+
else
|
|
61
|
+
out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.UnknownCryptFilter, undefined, r.detail ? { filter: r.detail } : undefined));
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
const PLUGIN_KEPT = new Set([types_1.PdfDetail.PluginPassed, types_1.PdfDetail.PluginScrubbed]);
|
|
65
|
+
/**
|
|
66
|
+
* `incomplete` marks a run that could not inspect the file: it stopped early, or found no page to read. finish()
|
|
67
|
+
* then rejects it without a score, whatever the actions say, because an override can make the finding that stopped
|
|
68
|
+
* it look harmless.
|
|
69
|
+
*/
|
|
70
|
+
function finish(findings, options, incomplete, version = '', pages, isOutput = false) {
|
|
71
|
+
if (incomplete)
|
|
72
|
+
return { status: 'rejected', score: null, risk: types_1.PdfRisk.Unknown, findings, version, pages };
|
|
73
|
+
const rejected = findings.some(f => f.action === 'reject');
|
|
74
|
+
// In an upload, a plugin rewrite or a metadata strip changes the file, so the output must be written.
|
|
75
|
+
// In an output being verified, the change is already there.
|
|
76
|
+
const strippable = findings.some(f => f.action === 'strip' || (!isOutput && (f.detail === types_1.PdfDetail.PluginScrubbed || f.detail === types_1.PdfDetail.Stripped)));
|
|
77
|
+
// The upload's score leaves out what plugins kept; the output's score counts it.
|
|
78
|
+
const { score, risk } = (0, score_1.scoreFindings)(isOutput ? findings : findings.filter(f => !PLUGIN_KEPT.has(f.detail)), options);
|
|
79
|
+
return { status: rejected ? 'rejected' : strippable ? 'strippable' : 'clean', score, risk, findings, version, pages };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Inspects `source` and leaves what it opens in `run`. It throws an I/O error instead of reporting a verdict on the
|
|
83
|
+
* PDF. That includes an error the parser recovered from as if the file were damaged, which `run.temp` keeps.
|
|
84
|
+
*/
|
|
85
|
+
async function analyze(run, source, options, depth, deadline, verify) {
|
|
86
|
+
let inspection;
|
|
87
|
+
try {
|
|
88
|
+
inspection = await analyzeInner(run, run.temp.watch(source), options, depth, deadline, verify);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
if (run.temp.ioError !== undefined)
|
|
92
|
+
throw run.temp.ioError;
|
|
93
|
+
if ((0, walker_1.stopsTree)(e) === 'DEFUSE_LIMIT' || isIoError(e))
|
|
94
|
+
throw e;
|
|
95
|
+
// Anything unexpected rejects the file rather than escaping as an exception.
|
|
96
|
+
const factory = new findings_1.FindingFactory(options.actionOverrides);
|
|
97
|
+
const f = limitFinding(factory, e) ?? factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.Unparseable, undefined, { reason: String(e?.message ?? e).slice(0, 120) });
|
|
98
|
+
inspection = finish([f], options, true);
|
|
99
|
+
}
|
|
100
|
+
if (run.temp.ioError !== undefined)
|
|
101
|
+
throw run.temp.ioError;
|
|
102
|
+
return inspection;
|
|
103
|
+
}
|
|
104
|
+
async function analyzeInner(run, source, options, depth, deadline, verify) {
|
|
105
|
+
const factory = new findings_1.FindingFactory(options.actionOverrides);
|
|
106
|
+
const limits = options.limits ?? {};
|
|
107
|
+
const findings = [];
|
|
108
|
+
if (limits.nestingDepth !== undefined && depth > limits.nestingDepth)
|
|
109
|
+
throw new walker_1.NestingLimitError('Nesting limit exceeded');
|
|
110
|
+
const size = await source.size();
|
|
111
|
+
if (limits.fileSize !== undefined && size > limits.fileSize)
|
|
112
|
+
return finish([factory.make(types_1.PdfCategory.Limit, types_1.PdfDetail.FileSize, undefined, { size })], options, true);
|
|
113
|
+
let doc;
|
|
114
|
+
try {
|
|
115
|
+
doc = await document_1.PdfDocument.open(source, {
|
|
116
|
+
password: options.password ?? '',
|
|
117
|
+
decompressedBytes: limits.decompressedBytes,
|
|
118
|
+
objectLimit: limits.objects,
|
|
119
|
+
deadline,
|
|
120
|
+
temp: run.temp,
|
|
121
|
+
memoryThreshold: options.memoryThreshold,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
const lf = limitFinding(factory, e);
|
|
126
|
+
if (lf)
|
|
127
|
+
findings.push(lf);
|
|
128
|
+
else if (e instanceof document_1.OpenError && e.kind === 'truncated')
|
|
129
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.Truncated));
|
|
130
|
+
else
|
|
131
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.Unparseable, undefined, { reason: String(e?.message ?? e).slice(0, 120) }));
|
|
132
|
+
return finish(findings, options, true);
|
|
133
|
+
}
|
|
134
|
+
run.doc = doc;
|
|
135
|
+
findings.push(...encryptionFindings(doc, factory));
|
|
136
|
+
if (doc.securityResult && doc.securityResult.status !== 'ok')
|
|
137
|
+
return finish(findings, options, true, doc.headerVersion);
|
|
138
|
+
if (doc.headerOffset > 0)
|
|
139
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.LeadingBytes, undefined, { bytes: doc.headerOffset }));
|
|
140
|
+
if (doc.trailingBytes > 0)
|
|
141
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.TrailingBytes, undefined, { bytes: doc.trailingBytes }));
|
|
142
|
+
if (doc.rebuilt)
|
|
143
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.XrefRebuilt));
|
|
144
|
+
// Earlier revisions matter when a newer one replaced or freed their objects; a pure addition hides nothing.
|
|
145
|
+
if (doc.issues.superseded)
|
|
146
|
+
findings.push(factory.make(types_1.PdfCategory.Structure, types_1.PdfDetail.IncrementalUpdates, undefined, { revisions: doc.sections, superseded: doc.issues.superseded }));
|
|
147
|
+
const walker = new walker_1.Walker(doc, { options, depth, temp: run.temp, factory, verify, deadline });
|
|
148
|
+
run.walker = walker;
|
|
149
|
+
let dead = 0;
|
|
150
|
+
try {
|
|
151
|
+
await walker.analyze();
|
|
152
|
+
dead = await doc.countDeadDefinitions();
|
|
153
|
+
// A signature keeps the bytes only when no other definition of an object sits anywhere in them, not even inside a
|
|
154
|
+
// live object, where it is harmless only to readers that keep to the cross-reference table.
|
|
155
|
+
run.signed = walker.signed && dead === 0 && verify === undefined && (await doc.countDeadDefinitions(true)) === 0;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
// A limit a plugin threw, such as a nested PDF over the nesting limit, rejects the whole upload, not just the attachment.
|
|
159
|
+
if (depth > 0 && e === walker.passedOn)
|
|
160
|
+
throw e;
|
|
161
|
+
const lf = limitFinding(factory, e) ?? factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.Unparseable, undefined, { reason: String(e?.message ?? e).slice(0, 120) });
|
|
162
|
+
findings.push(...walker.findings, lf);
|
|
163
|
+
return finish(findings, options, true, doc.headerVersion, walker.pages);
|
|
164
|
+
}
|
|
165
|
+
findings.push(...walker.findings);
|
|
166
|
+
const issues = doc.issues;
|
|
167
|
+
if (dead)
|
|
168
|
+
findings.push(factory.make(types_1.PdfCategory.Structure, types_1.PdfDetail.ShadowedObjects, undefined, { count: dead }));
|
|
169
|
+
if (issues.streamLengthWrong)
|
|
170
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.StreamLengthWrong, undefined, { count: issues.streamLengthWrong }));
|
|
171
|
+
if (issues.malformed)
|
|
172
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, undefined, { count: issues.malformed }));
|
|
173
|
+
if (issues.badOffsets)
|
|
174
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'cross-reference table', { reason: 'entries that do not point at their object', count: issues.badOffsets }));
|
|
175
|
+
if (issues.genMismatch)
|
|
176
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'cross-reference table', { reason: 'generation numbers that do not match', count: issues.genMismatch }));
|
|
177
|
+
if (issues.badXRefStm)
|
|
178
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'cross-reference table', { reason: '/XRefStm that leads to no xref stream', count: issues.badXRefStm }));
|
|
179
|
+
if (issues.xrefStmOverFree)
|
|
180
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'cross-reference table', { reason: '/XRefStm entries for objects the table marks free', count: issues.xrefStmOverFree }));
|
|
181
|
+
if (issues.badObjStm.size)
|
|
182
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, 'object stream', { reason: 'a header pdf.js cannot follow', count: issues.badObjStm.size }));
|
|
183
|
+
if (issues.refGen.size)
|
|
184
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, undefined, { reason: 'references whose generation does not match the object', count: issues.refGen.size }));
|
|
185
|
+
if (issues.duplicateKeys.size)
|
|
186
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, undefined, { reason: 'repeated dictionary keys', keys: [...issues.duplicateKeys].slice(0, 10).join(', ') }));
|
|
187
|
+
if (issues.structuralRefs) {
|
|
188
|
+
// The writer cannot keep a reference to an object or xref stream, so it goes whatever the override says.
|
|
189
|
+
const f = factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.MalformedObject, undefined, { reason: 'references to object or xref streams', count: issues.structuralRefs });
|
|
190
|
+
if (f.action === 'info')
|
|
191
|
+
f.action = 'strip';
|
|
192
|
+
findings.push(f);
|
|
193
|
+
}
|
|
194
|
+
for (const part of issues.memoryFallback)
|
|
195
|
+
findings.push(factory.make(types_1.PdfCategory.Processing, types_1.PdfDetail.MemoryFallback, undefined, { part }));
|
|
196
|
+
if (walker.pages === 0)
|
|
197
|
+
findings.push(factory.make(types_1.PdfCategory.Corrupted, types_1.PdfDetail.Unparseable, undefined, { reason: 'no pages' }));
|
|
198
|
+
const result = finish(findings, options, walker.pages === 0, doc.headerVersion, walker.pages, verify !== undefined);
|
|
199
|
+
if (result.status !== 'clean' || walker.removedAt === undefined)
|
|
200
|
+
return result;
|
|
201
|
+
// A removal no finding reported still shows in the status, and keeps a signed file from passing through unchanged.
|
|
202
|
+
const f = factory.make(types_1.PdfCategory.Processing, types_1.PdfDetail.ContentRemoved, walker.removedAt);
|
|
203
|
+
if (f.action === 'info')
|
|
204
|
+
f.action = 'strip';
|
|
205
|
+
findings.push(f);
|
|
206
|
+
return finish(findings, options, false, doc.headerVersion, walker.pages, verify !== undefined);
|
|
207
|
+
}
|
|
208
|
+
async function closeSource(source) {
|
|
209
|
+
try {
|
|
210
|
+
await source.close?.();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
/* ignore */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Inspects a PDF without changing it. The source is closed when the run ends. */
|
|
217
|
+
async function inspectPdfSource(source, options = {}) {
|
|
218
|
+
const run = { temp: new io_1.TempDir(options.tempDir) };
|
|
219
|
+
const deadline = options.limits?.timeMs !== undefined ? Date.now() + options.limits.timeMs : undefined;
|
|
220
|
+
try {
|
|
221
|
+
return await analyze(run, source, options, 0, deadline).catch(e => rethrowOrReject(e, options));
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await closeSource(source);
|
|
225
|
+
await endRun(run);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function rethrowOrReject(e, options) {
|
|
229
|
+
const lf = limitFinding(new findings_1.FindingFactory(options.actionOverrides), e);
|
|
230
|
+
if (!lf)
|
|
231
|
+
throw e;
|
|
232
|
+
return finish([lf], options, true);
|
|
233
|
+
}
|
|
234
|
+
/** Copies to the caller's sink. On failure it aborts the sink instead of closing it, so a partial copy does not look whole. */
|
|
235
|
+
async function deliver(from, sink) {
|
|
236
|
+
try {
|
|
237
|
+
await (0, io_1.copyToSink)(from, sink);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
try {
|
|
241
|
+
await sink.abort?.(e);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
/* the copy's error is the one to report */
|
|
245
|
+
}
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
await sink.close();
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Writes a defused copy of `source` to `sink`. Nothing reaches the sink unless the output passes inspection. The
|
|
252
|
+
* source is closed when the run ends.
|
|
253
|
+
*/
|
|
254
|
+
async function disarmPdfSource(source, sink, options = {}) {
|
|
255
|
+
return disarmAtDepth(source, sink, options, 0);
|
|
256
|
+
}
|
|
257
|
+
/** Internal entry that carries the nesting depth for attached PDFs, and the deadline they share with the upload. */
|
|
258
|
+
async function disarmAtDepth(source, sink, options, depth, deadline = options.limits?.timeMs !== undefined ? Date.now() + options.limits.timeMs : undefined) {
|
|
259
|
+
const run = { temp: new io_1.TempDir(options.tempDir) };
|
|
260
|
+
let input = source;
|
|
261
|
+
try {
|
|
262
|
+
let before;
|
|
263
|
+
try {
|
|
264
|
+
// A caller's source can change between reads, so analysis and the copy to the sink read one snapshot. An
|
|
265
|
+
// attached PDF's source already belongs to the engine, and analysis rejects a file over the size limit unread.
|
|
266
|
+
const fileSize = options.limits?.fileSize;
|
|
267
|
+
if (depth === 0 && (fileSize === undefined || (await source.size()) <= fileSize)) {
|
|
268
|
+
const snapshot = new io_1.SpillSink(run.temp, options.memoryThreshold ?? 8 * 1024 * 1024);
|
|
269
|
+
try {
|
|
270
|
+
await (0, io_1.copyToSink)(source, snapshot, () => {
|
|
271
|
+
if (deadline !== undefined && Date.now() > deadline)
|
|
272
|
+
throw new document_1.TimeLimitError('Time limit exceeded');
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await snapshot.close();
|
|
277
|
+
}
|
|
278
|
+
input = snapshot.source();
|
|
279
|
+
}
|
|
280
|
+
before = await analyze(run, input, options, depth, deadline);
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
if (depth > 0 && (0, walker_1.stopsTree)(e) === 'DEFUSE_LIMIT')
|
|
284
|
+
throw e;
|
|
285
|
+
before = rethrowOrReject(e, options);
|
|
286
|
+
}
|
|
287
|
+
const walker = run.walker;
|
|
288
|
+
if (before.status === 'rejected' || !walker)
|
|
289
|
+
return { status: 'rejected', before, removed: [] };
|
|
290
|
+
// Any other file is rewritten, so the output holds only what the walk understood. A signature over the whole
|
|
291
|
+
// file keeps a clean file byte for byte, since a rewrite would break it. A file that needed any repair to read
|
|
292
|
+
// does not qualify, whatever the overrides say, because other readers may repair it another way.
|
|
293
|
+
if (before.status === 'clean' && run.signed && options.preserveSignatures !== false && !before.findings.some(f => f.category === types_1.PdfCategory.Corrupted)) {
|
|
294
|
+
await deliver(input, sink);
|
|
295
|
+
return { status: 'clean', before, after: before, removed: [] };
|
|
296
|
+
}
|
|
297
|
+
const factory = new findings_1.FindingFactory(options.actionOverrides);
|
|
298
|
+
const outPath = await run.temp.file('.pdf');
|
|
299
|
+
const out = (0, io_1.fileSink)(outPath);
|
|
300
|
+
const expectedContent = walker.pageContent;
|
|
301
|
+
try {
|
|
302
|
+
await walker.write(out);
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
await out.close().catch(() => {
|
|
306
|
+
// The write's failure, handled below, is the one that counts.
|
|
307
|
+
});
|
|
308
|
+
if (run.temp.ioError !== undefined)
|
|
309
|
+
throw run.temp.ioError;
|
|
310
|
+
if (isIoError(e))
|
|
311
|
+
throw e;
|
|
312
|
+
const lf = limitFinding(factory, e);
|
|
313
|
+
before.findings.push(lf ?? factory.make(types_1.PdfCategory.Processing, types_1.PdfDetail.VerificationFailed, undefined, { reasons: `write failed: ${String(e?.message ?? e).slice(0, 100)}` }));
|
|
314
|
+
before.status = 'rejected';
|
|
315
|
+
// A limit stopped the run. A write that failed otherwise leaves the upload fully inspected, so its score stands.
|
|
316
|
+
if (lf) {
|
|
317
|
+
before.score = null;
|
|
318
|
+
before.risk = types_1.PdfRisk.Unknown;
|
|
319
|
+
}
|
|
320
|
+
return { status: 'rejected', before, removed: [] };
|
|
321
|
+
}
|
|
322
|
+
await out.close();
|
|
323
|
+
// A read the writer recovered from leaves an object out of the output.
|
|
324
|
+
if (run.temp.ioError !== undefined)
|
|
325
|
+
throw run.temp.ioError;
|
|
326
|
+
// The input document is no longer read; free its index before the output is analyzed.
|
|
327
|
+
await walker.releaseState();
|
|
328
|
+
const verifyOptions = {
|
|
329
|
+
actionOverrides: options.actionOverrides,
|
|
330
|
+
scoreWeights: options.scoreWeights,
|
|
331
|
+
scoreBands: options.scoreBands,
|
|
332
|
+
tempDir: options.tempDir,
|
|
333
|
+
memoryThreshold: options.memoryThreshold,
|
|
334
|
+
};
|
|
335
|
+
const outSource = (0, io_1.fileSource)(outPath);
|
|
336
|
+
try {
|
|
337
|
+
const check = { temp: new io_1.TempDir(options.tempDir) };
|
|
338
|
+
let after;
|
|
339
|
+
let outContent;
|
|
340
|
+
try {
|
|
341
|
+
after = await analyze(check, outSource, verifyOptions, depth, deadline, walker.kept);
|
|
342
|
+
outContent = check.walker?.pageContent ?? [];
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
await endRun(check);
|
|
346
|
+
}
|
|
347
|
+
const reasons = after.findings.filter(f => f.action !== 'info').map(f => `${f.category}/${f.detail}`);
|
|
348
|
+
// A check that stopped early verified nothing, even when the caller overrode the finding that stopped it to info.
|
|
349
|
+
if (after.status === 'rejected' && !reasons.length)
|
|
350
|
+
reasons.push('the output check stopped early');
|
|
351
|
+
if (after.pages !== before.pages)
|
|
352
|
+
reasons.push(`pages ${before.pages} -> ${after.pages}`);
|
|
353
|
+
// Every page must keep exactly the content it had; a lost content stream fails here.
|
|
354
|
+
if (outContent.length !== expectedContent.length || outContent.some((n, i) => n !== expectedContent[i]))
|
|
355
|
+
reasons.push('page content changed');
|
|
356
|
+
if (reasons.length) {
|
|
357
|
+
before.findings.push(factory.make(types_1.PdfCategory.Processing, types_1.PdfDetail.VerificationFailed, undefined, { reasons: reasons.slice(0, 10).join(', ') }));
|
|
358
|
+
before.status = 'rejected';
|
|
359
|
+
return { status: 'rejected', before, removed: [] };
|
|
360
|
+
}
|
|
361
|
+
await deliver(outSource, sink);
|
|
362
|
+
return { status: before.status === 'clean' ? 'clean' : 'defused', before, after, removed: before.findings.filter(f => f.action === 'strip') };
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
await closeSource(outSource);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
// An attached PDF's I/O error fails the upload's run too, instead of counting as a failed plugin.
|
|
370
|
+
if (depth > 0 && (isIoError(e) || (e !== undefined && e === run.temp.ioError)))
|
|
371
|
+
throw new walker_1.NestedIoError(e);
|
|
372
|
+
throw e;
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
if (input !== source)
|
|
376
|
+
await closeSource(input);
|
|
377
|
+
// An attached PDF's source belongs to the walker that decoded it.
|
|
378
|
+
if (depth === 0)
|
|
379
|
+
await closeSource(source);
|
|
380
|
+
await endRun(run);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/** Inspects PDF bytes. */
|
|
384
|
+
async function inspectPdf(bytes, options = {}) {
|
|
385
|
+
return inspectPdfSource((0, io_1.bufferSource)(bytes), options);
|
|
386
|
+
}
|
|
387
|
+
/** Defuses PDF bytes. `bytes` holds the output unless the file was rejected. */
|
|
388
|
+
async function disarmPdf(bytes, options = {}) {
|
|
389
|
+
const sink = (0, io_1.bufferSink)();
|
|
390
|
+
const result = await disarmPdfSource((0, io_1.bufferSource)(bytes), sink, options);
|
|
391
|
+
return result.status === 'rejected' ? result : { ...result, bytes: sink.result() };
|
|
392
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PdfDict } from './objects';
|
|
2
|
+
export declare class DecompressionLimitError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export declare class UnsupportedFilterError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Lists a stream's filters and their parameter dictionaries. A filter that is not a name, such as a reference left
|
|
8
|
+
* unresolved, gets an empty name that no decoder accepts: its bytes are still encoded, not plain.
|
|
9
|
+
*/
|
|
10
|
+
export declare function filtersOf(dict: PdfDict): Array<{
|
|
11
|
+
name: string;
|
|
12
|
+
parms?: PdfDict;
|
|
13
|
+
}>;
|
|
14
|
+
/** Growable byte buffer: one byte of memory per byte, where a JS number array costs a slot each. */
|
|
15
|
+
export declare class Out {
|
|
16
|
+
private buf;
|
|
17
|
+
len: number;
|
|
18
|
+
constructor(size?: number);
|
|
19
|
+
push(b: number): void;
|
|
20
|
+
take(): Uint8Array;
|
|
21
|
+
}
|
|
22
|
+
export declare const hexVal: (c: number) => number;
|
|
23
|
+
/**
|
|
24
|
+
* Decodes a stream chunk by chunk through its whole filter chain. No stage may produce more than `limit` bytes, and
|
|
25
|
+
* `checkTime` runs between pieces of work in every stage.
|
|
26
|
+
*/
|
|
27
|
+
export declare function decodeChunks(input: AsyncIterable<Uint8Array>, dict: PdfDict, limit?: number, checkTime?: () => void): AsyncGenerator<Uint8Array>;
|