@patrickdk77/pdf-defuse 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,7 @@ Removed by default:
69
69
  - Actions that reach outside the document: launching programs, links to other or embedded files, form submit and import, show and hide toggles, layer toggles, menu commands and media.
70
70
  - Links other than http, https and mailto, and links built to mislead. That covers credentials before the host, IP hosts, punycode or mixed-script hosts, encoded hosts, tooltips or alternate text that name a different site, links and push buttons that cover most of a page, and relative links with no base address.
71
71
  - Attached files, portfolios, XFA forms, slideshows, media and 3D annotations, and annotation types outside an allowlist.
72
- - Encryption with an empty password, or with the password you pass.
72
+ - Encryption with an empty password, or with a password you pass.
73
73
  - Earlier revisions, unreferenced objects and damaged structure. The output is one clean revision, unless it is a signed file that keeps its bytes, as described below.
74
74
 
75
75
  Kept: text, images, fonts, layout, bookmarks, form fields and their values, safe links and metadata.
@@ -121,7 +121,7 @@ A run that stops before it has seen the whole file rejects the file with a null
121
121
 
122
122
  | Option | Default | Purpose |
123
123
  |---|---|---|
124
- | `password` | `''` | Tried as the user password, then as the owner password, then as the empty password. See Passwords below |
124
+ | `password` | `''` | One password or a list. pdf-defuse tries each in order, as the user password and then as the owner password, and the empty password last. See Passwords below |
125
125
  | `limits` | none | `fileSize`, `objects`, `decompressedBytes`, `nestingDepth` and `timeMs`. A limit left out is not enforced |
126
126
  | `stripMetadata` | `false` | Remove the document information dictionary wherever it is referenced, and the XMP metadata of every object: the catalog, pages, images and the rest |
127
127
  | `actionOverrides` | none | Change the action for a category, or for a category and detail |
@@ -139,11 +139,13 @@ await disarmPdf(bytes, { actionOverrides: [{ category: 'JAVASCRIPT', action: 're
139
139
 
140
140
  ### Passwords
141
141
 
142
+ `password` takes one password or a list, because one upload can hold files with different passwords, such as a ZIP with one password and a PDF inside it with another. pdf-defuse tries each entry in order, as the user password and then as the owner password, and stops at the first that opens the file. The empty password comes after the list, so a file that opens without a password opens whatever the list holds. ENCRYPTED/USER_PASSWORD or ENCRYPTED/OWNER_PASSWORD says which kind of password opened the file. `pdfPlugin()` gives every attached PDF the list of the run that found it, and `pdfPlugin(options)` gives it the list in `options`.
143
+
142
144
  A revision 6 password, the AES-256 scheme of ISO 32000-2, is tried three ways: prepared with SASLprep, as that standard requires, prepared the way pdf.js prepares it, and as typed. The last opens files from tools that skip SASLprep. Other revisions use the password as typed.
143
145
 
144
146
  An empty owner password opens a file, as in qpdf, and ENCRYPTED/OWNER_PASSWORD reports it. pdf.js asks for a password there.
145
147
 
146
- A file that encrypts only its attached files, through a crypt filter with `/AuthEvent /EFOpen`, opens without the password, as in pdf.js, and ENCRYPTED/ATTACHMENTS_ONLY reports it. pdf-defuse removes the attached files the password does not decrypt and any other stream under their key, which ENCRYPTED/NO_KEY reports. pdf.js draws such a page blank.
148
+ A file that encrypts only its attached files, through a crypt filter with `/AuthEvent /EFOpen`, opens without a password, as in pdf.js, and ENCRYPTED/ATTACHMENTS_ONLY reports it. pdf-defuse removes the attached files that no password you pass decrypts, and any other stream under their key, which ENCRYPTED/NO_KEY reports. pdf.js draws such a page blank.
147
149
 
148
150
  ## Plugins
149
151
 
@@ -252,6 +254,8 @@ const result = await router.process(upload, sink, { depth: 0, deadline: Date.now
252
254
 
253
255
  `pdfPlugin(options)` runs every PDF it gets with those options. Built without options, it takes the options of the pdf-defuse run that found the file, which pdf-defuse passes under a symbol only it can read. A `pdfPlugin()` that another package calls with a context of its own runs with no options at all, so a master builds it with options.
254
256
 
257
+ `detectPdf(bytes, options)` and `detectPdfSource(source, options)` tell a router whether pdf-defuse reads a file. They return `{ mimeType, extension, offset }`, or undefined. A header in the first 1024 bytes makes the file a PDF at the header's offset. A file without one is a PDF only when `options.name` or `options.declaredType` says PDF and pdf-defuse opens it, the way pdf.js opens a file without a header. That open reads the whole file, and the answer then carries `heuristic`. A file that opens but is too broken to inspect still counts, and the inspection rejects it. `pdfPlugin` takes what detection finds. `detectPdfSource` leaves the source open, and throws with the `DEFUSE_LIMIT` code once `options.deadline` passes.
258
+
255
259
  The context holds `depth` and `deadline` and nothing PDF-specific, so any package can make one. `depth` is the depth of the file itself, the same number as `file.depth`. The upload is 0 and a file inside it is 1, whatever the formats. A container gives each file it finds its own depth plus one. `pdfPlugin` runs a PDF at the depth in its context and checks `limits.nestingDepth` against it, so a PDF in a ZIP in a PDF counts as depth 2. `deadline` is the `Date.now()` value the whole upload must finish by, and a container passes it on unchanged.
256
260
 
257
261
  A plugin returns its findings as `DefuseFinding` objects, whose `category` and `detail` are plain strings. The string enums of two packages never match each other. `PdfFinding`, with pdf-defuse's enums, fits `DefuseFinding`. pdf-defuse adds a contained file's findings to its own report with `attachment` set, and keeps the action each came with, except that `reject` becomes `strip`. `actionOverrides` apply to the findings a pdf-defuse run makes, never to those a plugin returns.
@@ -268,7 +272,7 @@ The `fileSize`, `objects` and `decompressedBytes` limits apply to one file and h
268
272
  pdf-defuse inspect <file> [options]
269
273
  pdf-defuse defuse <in> <out> [options]
270
274
 
271
- --password-file <path> read the password from a file, or set PDF_DEFUSE_PASSWORD
275
+ --password-file <path> read passwords from a file, one per line; PDF_DEFUSE_PASSWORD holds one
272
276
  --plugin <module> load plugins from a file or an installed package, repeatable
273
277
  --config <module> load a whole options object, repeatable; the other options apply on top
274
278
  --keep-type <type> keep attached files of this MIME type or file extension unchanged, repeatable
@@ -286,6 +290,8 @@ Use `-` for stdin or stdout. The tool copies stdin to a temporary file in `tempD
286
290
 
287
291
  The text report shows each control character as an escape such as `\u001b`. The JSON report escapes DEL and the C1 range too, which JSON itself does not require. A field name or a title from the PDF can hold an escape sequence that rewrites what the terminal shows, the verdict included.
288
292
 
293
+ `--password-file` reads one password per line. The tool drops a carriage return at the end of a line and skips blank lines, since the empty password is always tried. Spaces belong to the password. `PDF_DEFUSE_PASSWORD` holds one password, and a line break in it is part of that password. `--password-file` replaces it, and either one replaces a `password` that a `--config` module sets.
294
+
289
295
  `objects`, `nestingDepth` and `timeMs` take whole numbers. `fileSize` and `decompressedBytes` take sizes. Any other value is a usage error.
290
296
 
291
297
  A `--plugin` module exports `plugins`, an array, or `plugin`, a single plugin. Either can also sit on the default export, and a default export that is itself a plugin or an array of them works too. ES modules, CommonJS files and installed packages all load. Package names resolve from the working directory, and a package that exports only an `import` condition loads as well, through a subpath pattern such as `"./*"` too. `--plugin keep.js` loads `./keep.js` when the working directory has that file, and the installed package `keep.js` when it does not.
package/dist/cli.js CHANGED
@@ -50,7 +50,7 @@ const USAGE = `Usage:
50
50
  pdf-defuse defuse <in> <out> [options]
51
51
 
52
52
  Options:
53
- --password-file <path> read the password from a file, or set PDF_DEFUSE_PASSWORD
53
+ --password-file <path> read passwords from a file, one per line; PDF_DEFUSE_PASSWORD holds one
54
54
  --plugin <module> load plugins from a file or an installed package, repeatable
55
55
  --config <module> load a whole options object, repeatable; the other options apply on top
56
56
  --keep-type <type> keep contained files of this MIME type or file extension unchanged, repeatable
@@ -181,7 +181,11 @@ async function parse(argv) {
181
181
  };
182
182
  switch (a) {
183
183
  case '--password-file':
184
- password = (await fsp.readFile(next(), 'utf8')).replace(/\r?\n$/, '');
184
+ // The empty password is always tried, so a blank line adds nothing.
185
+ password = (await fsp.readFile(next(), 'utf8'))
186
+ .split('\n')
187
+ .map(line => line.replace(/\r$/, ''))
188
+ .filter(line => line !== '');
185
189
  break;
186
190
  case '--plugin':
187
191
  ordered.push(...pluginsFrom(await loadModule(next())));
@@ -0,0 +1,9 @@
1
+ import type { ByteSource, DetectOptions, FormatDetection } from './types';
2
+ /**
3
+ * Whether pdf-defuse reads `source` as a PDF. A header in the first 1024 bytes makes it one, at the header's offset.
4
+ * A file without one is a PDF only when its name or declared type says PDF and it opens as one, the way pdf.js opens
5
+ * a file without a header. That open reads the whole file. The source stays open.
6
+ */
7
+ export declare function detectPdfSource(source: ByteSource, options?: DetectOptions): Promise<FormatDetection | undefined>;
8
+ /** `detectPdfSource` over bytes in memory. */
9
+ export declare function detectPdf(bytes: Uint8Array, options?: DetectOptions): Promise<FormatDetection | undefined>;
package/dist/detect.js ADDED
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectPdfSource = detectPdfSource;
4
+ exports.detectPdf = detectPdf;
5
+ const document_1 = require("./document");
6
+ const io_1 = require("./io");
7
+ const sniff_1 = require("./sniff");
8
+ const walker_1 = require("./walker");
9
+ /** PDF readers look for the header in the first 1024 bytes, and so does PdfDocument when it opens a file. */
10
+ const HEADER_WINDOW = 1024;
11
+ const HEADER = Buffer.from('%PDF-', 'latin1');
12
+ const isIoError = (e) => typeof e?.code === 'string' && /^E[A-Z]+$/.test(e.code);
13
+ /**
14
+ * Whether pdf-defuse reads `source` as a PDF. A header in the first 1024 bytes makes it one, at the header's offset.
15
+ * A file without one is a PDF only when its name or declared type says PDF and it opens as one, the way pdf.js opens
16
+ * a file without a header. That open reads the whole file. The source stays open.
17
+ */
18
+ async function detectPdfSource(source, options = {}) {
19
+ const size = await source.size();
20
+ const head = await source.read(0, Math.min(size, HEADER_WINDOW));
21
+ const at = Buffer.from(head.buffer, head.byteOffset, head.byteLength).indexOf(HEADER);
22
+ if (at >= 0)
23
+ return { mimeType: 'application/pdf', extension: 'pdf', offset: at };
24
+ const claimed = [options.declaredType, (0, sniff_1.typeFromName)(options.name)].some(t => t !== undefined && (0, sniff_1.family)(t) === 'application/pdf');
25
+ if (!claimed)
26
+ return undefined;
27
+ const temp = new io_1.TempDir(options.tempDir);
28
+ try {
29
+ const doc = await document_1.PdfDocument.open(temp.watch(source), { deadline: options.deadline, temp, memoryThreshold: options.memoryThreshold });
30
+ await doc.release();
31
+ if (temp.ioError !== undefined)
32
+ throw temp.ioError;
33
+ return { mimeType: 'application/pdf', extension: 'pdf', offset: 0, heuristic: 'no PDF header, but the name or declared type says PDF and the file opens as one' };
34
+ }
35
+ catch (e) {
36
+ if (temp.ioError !== undefined)
37
+ throw temp.ioError;
38
+ if ((0, walker_1.stopsTree)(e) !== undefined || isIoError(e))
39
+ throw e;
40
+ // Any other failure to open, as in an inspection, means pdf-defuse does not read the file as a PDF.
41
+ return undefined;
42
+ }
43
+ finally {
44
+ await temp.cleanup();
45
+ }
46
+ }
47
+ /** `detectPdfSource` over bytes in memory. */
48
+ function detectPdf(bytes, options = {}) {
49
+ return detectPdfSource((0, io_1.bufferSource)(bytes), options);
50
+ }
@@ -61,7 +61,7 @@ interface DocumentIssues {
61
61
  memoryFallback: string[];
62
62
  }
63
63
  interface OpenOptions {
64
- password?: string;
64
+ password?: string | readonly string[];
65
65
  decompressedBytes?: number;
66
66
  objectLimit?: number;
67
67
  deadline?: number;
package/dist/document.js CHANGED
@@ -681,11 +681,16 @@ class PdfDocument {
681
681
  this.encryptRefs.add(num);
682
682
  const id = this.trailer.get('ID');
683
683
  const id0 = Array.isArray(id) && id[0] instanceof objects_1.PdfString ? id[0].bytes : new Uint8Array(0);
684
- const password = this.opts.password ?? '';
685
- let r = crypto_1.SecurityHandler.open(dict, id0, password);
686
- // A password meant for other files must not lock out one that opens without a password.
687
- if (r.status === 'password-required' && password !== '')
688
- r = crypto_1.SecurityHandler.open(dict, id0, '');
684
+ const given = this.opts.password ?? '';
685
+ // A password meant for other files must not lock out one that opens without a password, so the empty one comes last.
686
+ const passwords = new Set([...(typeof given === 'string' ? [given] : given), '']);
687
+ let r = { status: 'password-required' };
688
+ for (const password of passwords) {
689
+ this.checkTime();
690
+ r = crypto_1.SecurityHandler.open(dict, id0, password);
691
+ if (r.status !== 'password-required')
692
+ break;
693
+ }
689
694
  this.securityResult = r;
690
695
  if (r.status === 'ok')
691
696
  this.security = r.handler;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { DelimitedOptions, FormulaHandling } from './data-plugins';
2
2
  export { csvPlugin, jsonPlugin, tsvPlugin } from './data-plugins';
3
+ export { detectPdf, detectPdfSource } from './detect';
3
4
  export { disarmPdf, disarmPdfSource, inspectPdf, inspectPdfSource } from './engine';
4
5
  export { allFindingSpecs } from './findings';
5
6
  export { bufferSink, bufferSource, fileSink, fileSource, writableSink } from './io';
package/dist/index.js CHANGED
@@ -14,11 +14,14 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.scoreFindings = exports.DEFAULT_BANDS = exports.COMBINATIONS = exports.pdfPlugin = exports.passThrough = exports.writableSink = exports.fileSource = exports.fileSink = exports.bufferSource = exports.bufferSink = exports.allFindingSpecs = exports.inspectPdfSource = exports.inspectPdf = exports.disarmPdfSource = exports.disarmPdf = exports.tsvPlugin = exports.jsonPlugin = exports.csvPlugin = void 0;
17
+ exports.scoreFindings = exports.DEFAULT_BANDS = exports.COMBINATIONS = exports.pdfPlugin = exports.passThrough = exports.writableSink = exports.fileSource = exports.fileSink = exports.bufferSource = exports.bufferSink = exports.allFindingSpecs = exports.inspectPdfSource = exports.inspectPdf = exports.disarmPdfSource = exports.disarmPdf = exports.detectPdfSource = exports.detectPdf = exports.tsvPlugin = exports.jsonPlugin = exports.csvPlugin = void 0;
18
18
  var data_plugins_1 = require("./data-plugins");
19
19
  Object.defineProperty(exports, "csvPlugin", { enumerable: true, get: function () { return data_plugins_1.csvPlugin; } });
20
20
  Object.defineProperty(exports, "jsonPlugin", { enumerable: true, get: function () { return data_plugins_1.jsonPlugin; } });
21
21
  Object.defineProperty(exports, "tsvPlugin", { enumerable: true, get: function () { return data_plugins_1.tsvPlugin; } });
22
+ var detect_1 = require("./detect");
23
+ Object.defineProperty(exports, "detectPdf", { enumerable: true, get: function () { return detect_1.detectPdf; } });
24
+ Object.defineProperty(exports, "detectPdfSource", { enumerable: true, get: function () { return detect_1.detectPdfSource; } });
22
25
  var engine_1 = require("./engine");
23
26
  Object.defineProperty(exports, "disarmPdf", { enumerable: true, get: function () { return engine_1.disarmPdf; } });
24
27
  Object.defineProperty(exports, "disarmPdfSource", { enumerable: true, get: function () { return engine_1.disarmPdfSource; } });
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import cjs from './index.js';
2
- export const { csvPlugin, jsonPlugin, tsvPlugin, disarmPdf, disarmPdfSource, inspectPdf, inspectPdfSource, allFindingSpecs, bufferSink, bufferSource, fileSink, fileSource, writableSink, passThrough, pdfPlugin, COMBINATIONS, DEFAULT_BANDS, scoreFindings, PdfCategory, PdfDetail, PdfRisk } = cjs;
2
+ export const { csvPlugin, jsonPlugin, tsvPlugin, detectPdf, detectPdfSource, disarmPdf, disarmPdfSource, inspectPdf, inspectPdfSource, allFindingSpecs, bufferSink, bufferSource, fileSink, fileSource, writableSink, passThrough, pdfPlugin, COMBINATIONS, DEFAULT_BANDS, scoreFindings, PdfCategory, PdfDetail, PdfRisk } = cjs;
3
3
  export default cjs;
package/dist/plugins.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.passThrough = passThrough;
4
4
  exports.pdfPlugin = pdfPlugin;
5
+ const detect_1 = require("./detect");
5
6
  const engine_1 = require("./engine");
6
7
  const score_1 = require("./score");
7
8
  const sniff_1 = require("./sniff");
@@ -36,8 +37,9 @@ function pdfPlugin(options) {
36
37
  return {
37
38
  kind: 'file',
38
39
  name: 'pdf',
39
- // It can only read PDF bytes, so the content decides, whatever the name says.
40
- accepts: f => f.sniffedType === 'application/pdf',
40
+ // It takes what detectPdf finds to be a PDF: a header in the first 1024 bytes, or no header, a name or declared
41
+ // type that says PDF, and content that opens as one.
42
+ accepts: async (f) => f.sniffedType === 'application/pdf' || (await (0, detect_1.detectPdfSource)(f.source, { name: f.name, declaredType: f.declaredType })) !== undefined,
41
43
  async process(file, sink, context) {
42
44
  const run = options ?? context[walker_1.RUN_OPTIONS] ?? {};
43
45
  const r = await (0, engine_1.disarmAtDepth)(file.source, sink, run, context.depth, context.deadline);
package/dist/sniff.js CHANGED
@@ -95,7 +95,14 @@ function family(mime) {
95
95
  * of "page.html." is html.
96
96
  */
97
97
  function extensionOf(name) {
98
- return /\.([^./\\]+)$/.exec((name ?? '').replace(/[. ]+$/, ''))?.[1].toLowerCase();
98
+ // A loop and lastIndexOf, since /[. ]+$/ backtracks: a name of 40,000 dots took seconds to match.
99
+ const n = name ?? '';
100
+ let end = n.length;
101
+ while (end > 0 && (n[end - 1] === '.' || n[end - 1] === ' '))
102
+ end--;
103
+ const dot = n.lastIndexOf('.', end - 1);
104
+ const ext = dot < 0 ? '' : n.slice(dot + 1, end);
105
+ return ext && !/[/\\]/.test(ext) ? ext.toLowerCase() : undefined;
99
106
  }
100
107
  function typeFromName(name) {
101
108
  const ext = extensionOf(name);
package/dist/types.d.ts CHANGED
@@ -269,8 +269,11 @@ export interface PdfScoreBands {
269
269
  critical: number;
270
270
  }
271
271
  export interface PdfOptions {
272
- /** Default '', tried as the user password and then as the owner password. */
273
- password?: string;
272
+ /**
273
+ * Default ''. Each password is tried in order, as the user password and then as the owner password. The empty
274
+ * password comes after them, so a file that opens without a password opens whatever the list holds.
275
+ */
276
+ password?: string | string[];
274
277
  /** A limit left out is not enforced. */
275
278
  limits?: PdfLimits;
276
279
  /** Default false. */
@@ -295,3 +298,30 @@ export interface PdfOptions {
295
298
  */
296
299
  preserveSignatures?: boolean;
297
300
  }
301
+ /**
302
+ * A file's format as a package of the family finds it, among the formats that package reads. The family's router asks
303
+ * each package and routes the file by the answers.
304
+ */
305
+ export interface FormatDetection {
306
+ /** The format's MIME type, such as application/pdf. */
307
+ mimeType: string;
308
+ /** The format's usual extension, lowercase and without the dot. */
309
+ extension: string;
310
+ /** Where the format starts in the file: 0, or the offset of its data when other data comes first. */
311
+ offset: number;
312
+ /** Set when the match rests on more than the content, and says what it rests on. */
313
+ heuristic?: string;
314
+ }
315
+ /** What a detect function may know of a file besides its content. */
316
+ export interface DetectOptions {
317
+ /** The file's name, as the upload or the container that holds it gives it. */
318
+ name?: string;
319
+ /** The MIME type the upload or the container declares. */
320
+ declaredType?: string;
321
+ /** The Date.now() value detection must finish by, as `PluginContext.deadline`. */
322
+ deadline?: number;
323
+ /** Folder for temporary files. Defaults to `os.tmpdir()`. */
324
+ tempDir?: string;
325
+ /** Bytes held in memory before a temporary file takes over. */
326
+ memoryThreshold?: number;
327
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/pdf-defuse",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Sanitize and clean PDF files. Detects and removes JavaScript, attached files, launch actions, unsafe links and empty-password encryption, scores the risk, and writes a disarmed copy with bounded memory and no runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "patrickdk77",