@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 +6 -2
- package/dist/engine.js +3 -3
- package/dist/findings.js +8 -8
- package/dist/walker.d.ts +6 -1
- package/dist/walker.js +30 -13
- package/package.json +1 -1
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.
|
|
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
|
-
//
|
|
79
|
-
|
|
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',
|
|
50
|
-
def(types_1.PdfCategory.JavaScript, types_1.PdfDetail.PluginScrubbed, 'info',
|
|
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',
|
|
82
|
-
def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginScrubbed, 'info',
|
|
83
|
-
def(types_1.PdfCategory.EmbeddedFile, types_1.PdfDetail.PluginFailed, 'strip',
|
|
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',
|
|
107
|
-
def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginScrubbed, 'info',
|
|
108
|
-
def(types_1.PdfCategory.Form, types_1.PdfDetail.PluginFailed, 'strip',
|
|
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
|
-
/**
|
|
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
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|