@patrickdk77/pdf-defuse 0.2.4 → 0.3.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/README.md CHANGED
@@ -140,6 +140,8 @@ The score runs from 0 to 100. Each kind of finding has a weight: a launch action
140
140
 
141
141
  `scoreWeights` comes first, then the built-in weights. A kind of finding pdf-defuse does not know, such as one another package's plugin returned, scores at its `weight`, or 0 without one.
142
142
 
143
+ A script or file a plugin answered for scores only by the findings the plugin returned, at their weights. pdf-defuse adds nothing of its own for it, and the PLUGIN_PASSED and PLUGIN_SCRUBBED findings that record the answer weigh 0, in the upload's score and in the output's. A form whose scripts a plugin keeps, and in which the plugin finds nothing, scores 0. With no plugin to judge them, the same scripts score at pdf-defuse's own weights.
144
+
143
145
  The bands are NONE at 0, LOW from 1, MEDIUM from 25, HIGH from 50 and CRITICAL from 80.
144
146
 
145
147
  A run that stops before it has seen the whole file rejects the file with a null score and the UNKNOWN level. Runs stop at a limit, at encryption they cannot open, and at a file they cannot parse or that ends before its trailer. A file with no page to read gets the same result. This holds whatever `actionOverrides` say, because an override can make the finding that stopped the run look harmless. A file rejected for what it holds, for example JavaScript under an override that rejects it, keeps its score. So does a file whose output failed its own inspection.
@@ -178,7 +180,9 @@ A file that encrypts only its attached files, through a crypt filter with `/Auth
178
180
 
179
181
  ## Plugins
180
182
 
181
- Attached files and scripts are removed unless a plugin keeps them. A plugin says which files or scripts it accepts, then scrubs them, passes them through unchanged, or removes them after all. The first plugin that accepts handles it. If a plugin throws, or scrubs and writes nothing, the item is removed. An error with a `DEFUSE_LIMIT` or `DEFUSE_IO` code stops the whole run instead, as described under The defuse family. A file plugin that passes a file keeps its original bytes, or the bytes it wrote to the sink if it wrote any.
183
+ Attached files and scripts are removed unless a plugin keeps them. A plugin says which files or scripts it accepts, then scrubs them, passes them through unchanged, or removes them after all. The first plugin that accepts handles it. If a plugin throws, or scrubs and writes nothing, the item is removed.
184
+
185
+ The plugin's answer is final. For a script, the report then holds JAVASCRIPT/PLUGIN_PASSED, PLUGIN_SCRUBBED or PLUGIN_REMOVED and the plugin's findings, and none of pdf-defuse's own, such as JAVASCRIPT/OPEN_ACTION. An attached file gets EMBEDDED_FILE/PLUGIN_PASSED, PLUGIN_SCRUBBED or PLUGIN_REMOVED and the plugin's findings. pdf-defuse reports an item itself only when no plugin answered: a script no plugin accepts gets its JAVASCRIPT finding, and one whose plugin failed gets that finding and JAVASCRIPT/PLUGIN_FAILED. A file no plugin accepts is EMBEDDED_FILE/NO_PLUGIN, and a file whose plugin failed is EMBEDDED_FILE/PLUGIN_FAILED, which weighs what NO_PLUGIN does. An error with a `DEFUSE_LIMIT` or `DEFUSE_IO` code stops the whole run instead, as described under The defuse family. A file plugin that passes a file keeps its original bytes, or the bytes it wrote to the sink if it wrote any.
182
186
 
183
187
  Five plugins are bundled:
184
188
 
@@ -250,7 +254,7 @@ const scriptPlugin = {
250
254
  };
251
255
  ```
252
256
 
253
- A script plugin can also return `findings` with its result, saying what it found in the script. They go into the report at the script's location, unless a finding gives a finer one, such as the line. A `reject` among them is recorded as `strip`, since the plugin decides one script, not the file. [script-defuse](https://github.com/patrickdk77/script-defuse)'s `formScriptPlugin` keeps form calculations, formats and checks, and reports why it removed every other script.
257
+ A script plugin can also return `findings` with its result, saying what it found in the script. They go into the report at the script's location, unless a finding gives a finer one, such as the line. A `reject` among them is recorded as `strip`, since the plugin decides one script, not the file. Their `weight` is what they add to the score. For a script the plugin passed, the output's report holds them again. [script-defuse](https://github.com/patrickdk77/script-defuse)'s `formScriptPlugin` keeps form calculations, formats and checks, and reports why it removed every other script.
254
258
 
255
259
  ## The defuse family
256
260
 
package/dist/engine.js CHANGED
@@ -62,7 +62,6 @@ function encryptionFindings(doc, factory) {
62
62
  out.push(factory.make(types_1.PdfCategory.Encrypted, types_1.PdfDetail.UnknownCryptFilter, undefined, r.detail ? { filter: r.detail } : undefined));
63
63
  return out;
64
64
  }
65
- const PLUGIN_KEPT = new Set([types_1.PdfDetail.PluginPassed, types_1.PdfDetail.PluginScrubbed]);
66
65
  /**
67
66
  * `incomplete` marks a run that could not inspect the file: it stopped early, or found no page to read. finish()
68
67
  * then rejects it without a score, whatever the actions say, because an override can make the finding that stopped
@@ -75,8 +74,9 @@ function finish(findings, options, incomplete, version = '', pages, isOutput = f
75
74
  // In an upload, a plugin rewrite or a metadata strip changes the file, so the output must be written.
76
75
  // In an output being verified, the change is already there.
77
76
  const strippable = findings.some(f => f.action === 'strip' || (!isOutput && (f.detail === types_1.PdfDetail.PluginScrubbed || f.detail === types_1.PdfDetail.Stripped)));
78
- // The upload's score leaves out what plugins kept; the output's score counts it.
79
- const { score, risk } = (0, score_1.scoreFindings)(isOutput ? findings : findings.filter(f => !PLUGIN_KEPT.has(f.detail)), options);
77
+ // What a plugin decided counts at the weights of the plugin's own findings; the PLUGIN_* findings that record its
78
+ // answer weigh nothing.
79
+ const { score, risk } = (0, score_1.scoreFindings)(findings, options);
80
80
  return { status: rejected ? 'rejected' : strippable ? 'strippable' : 'clean', score, risk, findings, version, pages };
81
81
  }
82
82
  /**
package/dist/findings.js CHANGED
@@ -46,8 +46,8 @@ def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Rendition, 'strip', 50, 'A
46
46
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Url, 'strip', 50, 'A link uses a javascript: URL');
47
47
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Unattached, 'strip', 50, 'A JavaScript entry sits outside any action');
48
48
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.Xfa, 'strip', 50, 'An XFA form script, in <language>, runs on a form event');
49
- def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, 'info', 40, 'JavaScript kept unchanged by plugin <plugin>');
50
- def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, 'info', 20, 'JavaScript rewritten and kept by plugin <plugin>');
49
+ def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, 'info', 0, 'JavaScript kept unchanged by plugin <plugin>');
50
+ def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, 'info', 0, 'JavaScript rewritten and kept by plugin <plugin>');
51
51
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginFailed, 'strip', 0, 'Plugin <plugin> failed, so the JavaScript was removed');
52
52
  def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginRemoved, 'strip', 0, 'JavaScript removed by plugin <plugin>');
53
53
  // ACTION
@@ -78,9 +78,9 @@ def(types_1.PdfCategory.Link, types_1.PdfDetail.PathTrick, 'strip', 40, 'A relat
78
78
  // EMBEDDED_FILE
79
79
  def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.NoPlugin, 'strip', 30, '<Attached file "{name}"|An attached file> (<{sniffed}|{type}|unknown type>) removed. No plugin accepts this type');
80
80
  def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.TypeMismatch, 'info', 40, 'The types of <attached file "{name}"|an unnamed attached file> disagree: <its name says {nameType}|its name has no known extension>, <it is declared {declared}|it has no declared type>, and <its content looks like {sniffed}|its content is not recognized>');
81
- def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginPassed, 'info', 20, 'Attached file kept by plugin <plugin> with nothing removed');
82
- def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginScrubbed, 'info', 10, 'Attached file cleaned and kept by plugin <plugin>');
83
- def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginFailed, 'strip', 0, 'Plugin <plugin> failed, so the attached file was removed');
81
+ def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginPassed, 'info', 0, 'Attached file kept by plugin <plugin> with nothing removed');
82
+ def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginScrubbed, 'info', 0, 'Attached file cleaned and kept by plugin <plugin>');
83
+ def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginFailed, 'strip', 30, 'Plugin <plugin> failed, so the attached file was removed');
84
84
  def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginRemoved, 'strip', 0, 'Attached file removed by plugin <plugin>');
85
85
  def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.Portfolio, 'strip', 30, 'PDF is a portfolio of files');
86
86
  // MEDIA
@@ -103,9 +103,9 @@ def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaConnection, 'strip', 40, 'An
103
103
  def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExternalObject, 'strip', 60, 'An XFA form embeds an external object');
104
104
  def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaExternalReference, 'strip', 40, 'An XFA form names something outside the PDF to load');
105
105
  def(types_1.PdfCategory.Form, types_1.PdfDetail.XfaImage, 'strip', 20, 'An XFA form image (<type|unknown type>) removed. <reason|No plugin accepts this type>');
106
- def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginPassed, 'info', 10, 'XFA form image kept by plugin <plugin> with nothing removed');
107
- def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginScrubbed, 'info', 5, 'XFA form image cleaned and kept by plugin <plugin>');
108
- def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginFailed, 'strip', 0, 'Plugin <plugin> failed, so the XFA form image was removed');
106
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginPassed, 'info', 0, 'XFA form image kept by plugin <plugin> with nothing removed');
107
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginScrubbed, 'info', 0, 'XFA form image cleaned and kept by plugin <plugin>');
108
+ def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginFailed, 'strip', 20, 'Plugin <plugin> failed, so the XFA form image was removed');
109
109
  def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginRemoved, 'strip', 0, 'XFA form image removed by plugin <plugin>');
110
110
  def(types_1.PdfCategory.Form, types_1.PdfDetail.CalculationOrder, 'strip', 20, 'Form calculation order removed with the calculation scripts it ran');
111
111
  // SIGNATURE
package/dist/walker.d.ts CHANGED
@@ -6,7 +6,10 @@ import { type ByteSink, type DefuseFinding, type PdfOptions, type PluginContext
6
6
  interface KeptItem {
7
7
  result: 'passed' | 'scrubbed';
8
8
  plugin: string;
9
- /** For a file: the findings inside the kept bytes, which go into the output's report. */
9
+ /**
10
+ * What goes into the output's report for it: for a file, the findings inside the kept bytes, and for a script the
11
+ * plugin passed, what the plugin found in it.
12
+ */
10
13
  findings?: DefuseFinding[];
11
14
  }
12
15
  export interface VerifyExpectations {
@@ -103,6 +106,8 @@ export declare class Walker {
103
106
  private againBudget;
104
107
  /** A script's findings from visits of removed content, held until the walk ends so a later real visit that keeps it can correct them. */
105
108
  private readonly shadowScripts;
109
+ /** Scripts a plugin answered for. Its answer is final, so what a shadow visit made of them is not reported. */
110
+ private readonly answeredScripts;
106
111
  /** Information dictionaries and XMP streams removed for the caller. */
107
112
  private readonly strippedMeta;
108
113
  /** Bytes of scrubbed attachments held in memory until the write. */
package/dist/walker.js CHANGED
@@ -336,6 +336,8 @@ class Walker {
336
336
  againBudget = 16 * 1024 * 1024;
337
337
  /** A script's findings from visits of removed content, held until the walk ends so a later real visit that keeps it can correct them. */
338
338
  shadowScripts = new Map();
339
+ /** Scripts a plugin answered for. Its answer is final, so what a shadow visit made of them is not reported. */
340
+ answeredScripts = new Set();
339
341
  /** Information dictionaries and XMP streams removed for the caller. */
340
342
  strippedMeta = new Set();
341
343
  /** Bytes of scrubbed attachments held in memory until the write. */
@@ -1052,9 +1054,10 @@ class Walker {
1052
1054
  d.types.decoded = undefined;
1053
1055
  await (await decoded?.catch(() => undefined))?.close?.();
1054
1056
  }
1055
- for (const list of this.shadowScripts.values())
1056
- for (const f of list)
1057
- this.push(f);
1057
+ for (const [key, list] of this.shadowScripts)
1058
+ if (!this.answeredScripts.has(key))
1059
+ for (const f of list)
1060
+ this.push(f);
1058
1061
  if (this.dropped.size || this.refusedSlots.size)
1059
1062
  this.removedAt ??= 'document';
1060
1063
  if (this.coPresent && this.calcRemoved && this.keptCalcFields.size === 0)
@@ -1898,6 +1901,7 @@ class Walker {
1898
1901
  const verify = sc.language ? this.cfg.verify?.scripts.get(sc.text) : undefined;
1899
1902
  if (verify) {
1900
1903
  this.emit(types_1.PdfCategory.JavaScript, verify.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, location, { plugin: verify.plugin });
1904
+ this.pushScript(verify.findings, location);
1901
1905
  return { keep: true };
1902
1906
  }
1903
1907
  const language = sc.language === 'formcalc' ? 'FormCalc' : sc.language === 'javascript' ? 'JavaScript' : short(sc.contentType || 'an unreadable form', 40);
@@ -1905,6 +1909,7 @@ class Walker {
1905
1909
  let decision = { keep: f.action !== 'strip' && sc.language !== undefined };
1906
1910
  let answer;
1907
1911
  let found;
1912
+ let answered = false;
1908
1913
  if (f.action === 'strip' && sc.language) {
1909
1914
  for (const plugin of this.options.scriptPlugins ?? []) {
1910
1915
  const script = {
@@ -1923,10 +1928,11 @@ class Walker {
1923
1928
  const r = await plugin.process(script);
1924
1929
  if (Array.isArray(r.findings))
1925
1930
  found = r.findings;
1931
+ answered = r.result === 'passed' || r.result === 'removed' || (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length > 0);
1926
1932
  if (r.result === 'passed') {
1927
1933
  decision = { keep: true };
1928
1934
  answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, location, data);
1929
- this.kept.scripts.set(sc.text, { result: 'passed', plugin: plugin.name });
1935
+ this.kept.scripts.set(sc.text, { result: 'passed', plugin: plugin.name, findings: found });
1930
1936
  }
1931
1937
  else if (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length) {
1932
1938
  decision = { keep: true, text: r.text };
@@ -1939,20 +1945,23 @@ class Walker {
1939
1945
  }
1940
1946
  catch (e) {
1941
1947
  this.passOn(e);
1948
+ answered = false;
1942
1949
  answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginFailed, location, data);
1943
1950
  }
1944
1951
  break;
1945
1952
  }
1946
- if (decision.keep)
1947
- f.action = 'info';
1948
1953
  }
1949
1954
  // A script in a language no plugin reads goes, whatever the override says.
1950
1955
  if (!sc.language && f.action === 'info')
1951
1956
  f.action = 'strip';
1952
- this.push(f);
1957
+ // A plugin's answer is final: the report holds the answer and what the plugin found, at the plugin's weights, and
1958
+ // nothing of this package's own. Its own finding stands when no plugin answered.
1959
+ if (!answered)
1960
+ this.push(f);
1953
1961
  if (answer)
1954
1962
  this.push(answer);
1955
- this.pushScript(found, location);
1963
+ if (answered)
1964
+ this.pushScript(found, location);
1956
1965
  return decision;
1957
1966
  }
1958
1967
  /**
@@ -2570,6 +2579,7 @@ class Walker {
2570
2579
  const verify = src !== undefined ? this.cfg.verify?.scripts.get(src) : undefined;
2571
2580
  if (verify) {
2572
2581
  this.emit(types_1.PdfCategory.JavaScript, verify.result === 'passed' ? types_1.PdfDetail.PluginPassed : types_1.PdfDetail.PluginScrubbed, ctx.location, { plugin: verify.plugin });
2582
+ this.pushScript(verify.findings, ctx.location);
2573
2583
  if (first)
2574
2584
  return first;
2575
2585
  const d = { keep: true };
@@ -2581,6 +2591,7 @@ class Walker {
2581
2591
  let decision = { keep: f.action !== 'strip' };
2582
2592
  let answer;
2583
2593
  let found;
2594
+ let answered = false;
2584
2595
  if (f.action === 'strip' && !ctx.shadow && src !== undefined) {
2585
2596
  for (const plugin of this.options.scriptPlugins ?? []) {
2586
2597
  const script = {
@@ -2599,11 +2610,12 @@ class Walker {
2599
2610
  const r = await plugin.process(script);
2600
2611
  if (Array.isArray(r.findings))
2601
2612
  found = r.findings;
2613
+ answered = r.result === 'passed' || r.result === 'removed' || (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length > 0);
2602
2614
  if (r.result === 'passed') {
2603
2615
  decision = { keep: true };
2604
2616
  answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginPassed, ctx.location, data);
2605
2617
  if (!first)
2606
- this.kept.scripts.set(src, { result: 'passed', plugin: plugin.name });
2618
+ this.kept.scripts.set(src, { result: 'passed', plugin: plugin.name, findings: found });
2607
2619
  }
2608
2620
  else if (r.result === 'scrubbed' && typeof r.text === 'string' && r.text.length) {
2609
2621
  decision = { keep: true, text: r.text };
@@ -2619,12 +2631,11 @@ class Walker {
2619
2631
  }
2620
2632
  catch (e) {
2621
2633
  this.passOn(e);
2634
+ answered = false;
2622
2635
  answer = this.cfg.factory.make(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginFailed, ctx.location, data);
2623
2636
  }
2624
2637
  break;
2625
2638
  }
2626
- if (decision.keep)
2627
- f.action = 'info';
2628
2639
  }
2629
2640
  if (ctx.shadow) {
2630
2641
  // Recorded when the walk ends, after any real visit that keeps the script has corrected it.
@@ -2635,10 +2646,16 @@ class Walker {
2635
2646
  this.shadowScripts.set(key, [f]);
2636
2647
  return { keep: false };
2637
2648
  }
2638
- this.push(f);
2649
+ // A plugin's answer is final: the report holds the answer and what the plugin found, at the plugin's weights, and
2650
+ // nothing of this package's own. Its own finding stands when no plugin answered.
2651
+ if (answered)
2652
+ this.answeredScripts.add(key);
2653
+ else
2654
+ this.push(f);
2639
2655
  if (answer)
2640
2656
  this.push(answer);
2641
- this.pushScript(found, ctx.location);
2657
+ if (answered)
2658
+ this.pushScript(found, ctx.location);
2642
2659
  if (first) {
2643
2660
  // One object holds one text, so a different rewrite is a refusal too, and only the slot can report it.
2644
2661
  if (first.keep && (!decision.keep || decision.text !== first.text)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/pdf-defuse",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
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",