@patrickdk77/script-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 +187 -2
- package/dist/analyze.d.ts +22 -0
- package/dist/analyze.js +770 -0
- package/dist/api.d.ts +82 -0
- package/dist/api.js +698 -0
- package/dist/engine.d.ts +12 -0
- package/dist/engine.js +18 -0
- package/dist/findings.d.ts +19 -0
- package/dist/findings.js +79 -0
- package/dist/formcalc.d.ts +10 -0
- package/dist/formcalc.js +792 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +32 -0
- package/dist/index.mjs +3 -0
- package/dist/plugins.d.ts +28 -0
- package/dist/plugins.js +29 -0
- package/dist/score.d.ts +17 -0
- package/dist/score.js +51 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.js +70 -0
- package/package.json +84 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 patrickdk77
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,188 @@
|
|
|
1
|
-
#
|
|
1
|
+
# script-defuse
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tell a PDF form's scripts from the rest. script-defuse parses a script and calls it a form script when every call and every property it uses formats, checks or calculates form fields. It reads AcroForm JavaScript, and the JavaScript and FormCalc of XFA forms. Any other script is unsafe, and its findings name what it reaches and on which line: a web address, a form submit, mail, another document, an attached file, the network, code built from a string, or text it hides.
|
|
4
|
+
|
|
5
|
+
It plugs into [pdf-defuse](https://github.com/patrickdk77/pdf-defuse) as a script plugin, so a fillable form keeps its calculations, formats and checks while every other script goes. It parses with [acorn](https://github.com/acornjs/acorn), its one runtime dependency, and follows the shapes of the other defuse packages.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install @patrickdk77/script-defuse
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Node 18 or later.
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
const { inspectScript } = require('@patrickdk77/script-defuse');
|
|
17
|
+
|
|
18
|
+
const r = inspectScript('event.value = this.getField("Qty").value * this.getField("Price").value;');
|
|
19
|
+
r.verdict; // 'form' or 'unsafe'
|
|
20
|
+
r.score; // 0 to 100
|
|
21
|
+
r.risk; // 'NONE', 'LOW', 'MEDIUM', 'HIGH' or 'CRITICAL'
|
|
22
|
+
r.findings; // [{ category, detail, description, action, location, data, weight }]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
ES modules work too: `import { inspectScript } from '@patrickdk77/script-defuse'`.
|
|
26
|
+
|
|
27
|
+
### With pdf-defuse
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const { disarmPdf } = require('@patrickdk77/pdf-defuse');
|
|
31
|
+
const { formScriptPlugin } = require('@patrickdk77/script-defuse');
|
|
32
|
+
|
|
33
|
+
const result = await disarmPdf(bytes, { scriptPlugins: [formScriptPlugin()] });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
pdf-defuse removes every script unless a plugin keeps it. This plugin keeps the form scripts and removes the rest. From pdf-defuse 0.2.4 the plugin's findings go into the PDF's report at each script's location.
|
|
37
|
+
|
|
38
|
+
Make one plugin per PDF. pdf-defuse hands it the document-level scripts first, and the plugin remembers the globals of the ones it kept, so a field script may call a helper they define. A field script that calls a helper from a document script the plugin removed is removed too.
|
|
39
|
+
|
|
40
|
+
With [file-defuse](https://github.com/patrickdk77/file-defuse), pass it in the pdf section: `disarmFile(bytes, { pdf: { scriptPlugins: [formScriptPlugin()] } })`.
|
|
41
|
+
|
|
42
|
+
## What a form script may use
|
|
43
|
+
|
|
44
|
+
An allowlist decides. A script that uses anything not on it is unsafe, a viewer API script-defuse does not know included.
|
|
45
|
+
|
|
46
|
+
- Fields: `this.getField(name)`, or `getField(name)`, `event.target` and `event.source`, and a field's value, display, colors, font, list items and check state. `this.numFields`, `getNthFieldName`, `calculateNow` and `resetForm`.
|
|
47
|
+
- The event: `event.value`, `change`, `rc`, `willCommit`, `selStart`, `selEnd` and the rest of what a format, keystroke, validate or calculate script reads and sets.
|
|
48
|
+
- Acrobat's form helpers, the AForm functions: `AFNumber_Format`, `AFSimple_Calculate`, `AFDate_FormatEx`, `AFRange_Validate`, `AFSpecial_Keystroke` and the others.
|
|
49
|
+
- Formatting: `util.printf`, `util.printd`, `util.printx` and `util.scand`, and the `color` and `display` constants.
|
|
50
|
+
- Messages: `app.alert`, `app.response` and `app.beep`, and reading `app.viewerVersion`, `viewerType`, `platform` and `language`. A prompt that asks for a password is SCRIPT_API/PASSWORD_PROMPT, since a form takes a password in a field of its own.
|
|
51
|
+
- The document's `nocache`, `noautocomplete`, `zoom`, `zoomType`, `layout`, `delay` and `pageNum`, which a script may set, and `scroll`.
|
|
52
|
+
- `this.xfa`, or `xfa` alone, in a document that has an XFA form. A bookmark can move to a part of the form that way, and the XFA rules below check everything it reaches.
|
|
53
|
+
- The language: `Math`, `Number`, `String`, `Date`, `RegExp`, `JSON`, arrays and objects, and the script's own functions and variables.
|
|
54
|
+
|
|
55
|
+
A form may store its own data on a field, such as `event.target.oldVal`. A name the script sets on `this` is a global, as a viewer resolves it, so `this.ADBE = new Object()` followed by `ADBE.x` is the script's own data too. So is a name it assigns without declaring it, unless the document has a property of that name. `viewState = s` and `this.viewState = s` set the document's property, so script-defuse checks them as writes to the document, and a property outside the list above is SCRIPT_API/UNKNOWN.
|
|
56
|
+
|
|
57
|
+
script-defuse follows each value through the script. A local that holds `this.getField("Total")` is a field wherever it is used, and one that holds `this` is the document. A value it cannot place, such as a function parameter, might hold a viewer object, so a viewer API name on it, such as `submitForm`, gets the finding the viewer object would.
|
|
58
|
+
|
|
59
|
+
## XFA forms
|
|
60
|
+
|
|
61
|
+
pdf-defuse 0.2.4 hands over an XFA form's scripts with `form: 'xfa'` and their `language`. Pass the same in `context` to `inspectScript`.
|
|
62
|
+
|
|
63
|
+
XFA JavaScript runs against the XFA object model, with Acrobat's objects beside it.
|
|
64
|
+
|
|
65
|
+
- `this` is the node that holds the script, and a bare name is a node the SOM finds, such as `Total.rawValue` or `form1.Page1.Name`. Any member of a node counts as the form's own, unless a viewer object has a member of that name that leaves the form.
|
|
66
|
+
- `xfa.resolveNode` and `resolveNodes` find nodes by SOM path, computed or not. `xfa.form`, `template`, `data`, `datasets` and `record` are trees of nodes.
|
|
67
|
+
- `xfa.host` gives message boxes, focus, pages, `resetData` and version checks, and `xfa.layout` gives pages and positions. `event` and `xfa.event` give the XFA event's members, such as `change`, `newText` and `prevText`. `event.target` is the Acrobat document.
|
|
68
|
+
- Forms often name a script object `util` or `color`. A name Acrobat's `util` or `color` lacks is then a function of the script object.
|
|
69
|
+
- `xfa.datasets.createNode`, `loadXML` and `assignNode` on the data, and `XMLData.parse` with entities turned off, build and read data.
|
|
70
|
+
|
|
71
|
+
These are removed wherever a node, a parameter or a variable reaches them:
|
|
72
|
+
|
|
73
|
+
- `xfa.host.gotoURL`, `exportData`, `importData` and `print`.
|
|
74
|
+
- `xfa.connectionSet`, `xfa.sourceSet` and any `execute`, which call a web service or a database.
|
|
75
|
+
- `xfa.signature`.
|
|
76
|
+
- A node created with a type that carries script, actions or outside content, such as `createNode("script")`, or with a type the script computes.
|
|
77
|
+
- `loadXML` and `assignNode` on anything but the data, since they can add script to the form.
|
|
78
|
+
- `applyXSL`, which runs a stylesheet.
|
|
79
|
+
- Setting a node's `href`, `usehref` or another attribute that names something outside the form, whether directly or with `setAttribute`.
|
|
80
|
+
- `XMLData.parse` with entities left on.
|
|
81
|
+
|
|
82
|
+
FormCalc has a parser of its own and the same rules. A FormCalc script keeps arithmetic, comparisons, `if`, `while`, `for` and `foreach`, the functions and variables it declares, and the built-in functions except these:
|
|
83
|
+
|
|
84
|
+
- `Get`, `Post` and `Put`, which reach a URL, are SCRIPT_EXTERNAL/NETWORK.
|
|
85
|
+
- `Eval` is SCRIPT_DYNAMIC_CODE/EVAL.
|
|
86
|
+
- `Decode` is SCRIPT_OBFUSCATION/DECODE.
|
|
87
|
+
- A function that FormCalc lacks and the script did not declare is SCRIPT_API/UNKNOWN.
|
|
88
|
+
|
|
89
|
+
References such as `$host.messageBox`, `$.rawValue`, `xfa.event.change` and `$record.Name` go through the tables the JavaScript rules use, so `$host.gotoURL` and `xfa.connectionSet` go in FormCalc too. `Concat` with many tiny pieces, `Replace` on a string the script wrote itself, encoded or long strings, and `\u` escapes of plain ASCII strip a FormCalc script as they strip a JavaScript one. A script FormCalc cannot parse is SCRIPT_SYNTAX/UNPARSEABLE. That includes JavaScript written in a script element with no content type, which XFA reads as FormCalc and a viewer cannot run.
|
|
90
|
+
|
|
91
|
+
An XFA script defines no globals for other scripts, which reach its functions through the node that holds it.
|
|
92
|
+
|
|
93
|
+
## Obfuscation
|
|
94
|
+
|
|
95
|
+
A form script has nothing to hide, so each of these strips the script:
|
|
96
|
+
|
|
97
|
+
- `String.fromCharCode` and `fromCodePoint`.
|
|
98
|
+
- `unescape`, `escape`, `decodeURIComponent`, `decodeURI`, `atob` and `btoa`.
|
|
99
|
+
- A number turned into text in a base past 10, as `(1525081).toString(36)` spells word.
|
|
100
|
+
- `replace` or `split` on a string the script wrote itself, as `"qwqoqrqd".replace(/q/g, "")` uncovers hidden text.
|
|
101
|
+
- A string joined from 6 or more pieces of one or two characters, or from 25 or more pieces.
|
|
102
|
+
- Three or more `\x` or `\u` escapes that write printable ASCII.
|
|
103
|
+
- A string of base64, hex, `%u` or `\x` escapes long enough to carry a payload.
|
|
104
|
+
- A string over 64 KB, or over 1000 characters with a run of 64 that has no whitespace, and `repeat` past 1000. Text and tables of numbers break into words, so a long message or a CRC table stays.
|
|
105
|
+
- Arithmetic on three or more number literals with one in hex, such as `0x1a2+0x3b4+-0x556`.
|
|
106
|
+
- A viewer API reached by a quoted name, such as `app["alert"]`.
|
|
107
|
+
- A property read by a name computed at run time, unless the object is the script's own data or the key can only be a number.
|
|
108
|
+
- `eval`, `Function`, timers that take a string, `arguments.callee` and a function's own `toString`.
|
|
109
|
+
|
|
110
|
+
## Findings
|
|
111
|
+
|
|
112
|
+
| Kind | Action | Weight | When |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| SCRIPT_FORM/COMPUTATION | info | 0 | The script only formats, checks or calculates form fields |
|
|
115
|
+
| SCRIPT_EXTERNAL/URL | strip | 60 | `app.launchURL`, `this.getURL`, `xfa.host.gotoURL`, setting a node's `href` |
|
|
116
|
+
| SCRIPT_EXTERNAL/SUBMIT | strip | 50 | `this.submitForm` |
|
|
117
|
+
| SCRIPT_EXTERNAL/MAIL | strip | 50 | `this.mailDoc`, `mailForm`, `app.mailMsg` |
|
|
118
|
+
| SCRIPT_EXTERNAL/OPEN_DOCUMENT | strip | 60 | `app.openDoc`, `app.newDoc`, `app.activeDocs` |
|
|
119
|
+
| SCRIPT_EXTERNAL/DATA_OBJECT | strip | 70 | `exportDataObject`, `importDataObject` and the other attached-file calls |
|
|
120
|
+
| SCRIPT_EXTERNAL/IMPORT_EXPORT | strip | 50 | FDF, XFDF and text import and export, page insertion, icon import, `xfa.host.exportData` and `importData`, `XMLData.parse` with entities |
|
|
121
|
+
| SCRIPT_EXTERNAL/NETWORK | strip | 70 | `Net`, `SOAP`, `Collab`, `ADBC`, `RSS`, XFA connections and `execute`, FormCalc's `Get`, `Post` and `Put` |
|
|
122
|
+
| SCRIPT_EXTERNAL/MENU_COMMAND | strip | 60 | `app.execMenuItem`, `app.execDialog` |
|
|
123
|
+
| SCRIPT_EXTERNAL/PRINT | strip | 20 | `this.print`, `xfa.host.print` |
|
|
124
|
+
| SCRIPT_EXTERNAL/SAVE | strip | 30 | `this.saveAs`, `closeDoc` |
|
|
125
|
+
| SCRIPT_DYNAMIC_CODE/EVAL | strip | 80 | `eval`, `Function`, `import()`, FormCalc's `Eval`, `applyXSL` |
|
|
126
|
+
| SCRIPT_DYNAMIC_CODE/TIMER | strip | 70 | `app.setTimeOut`, `setInterval` |
|
|
127
|
+
| SCRIPT_DYNAMIC_CODE/INSTALL_SCRIPT | strip | 70 | `addScript`, `setAction`, `setPageAction`, menu and toolbar items, XFA nodes created to carry script, `loadXML` and `assignNode` outside the data |
|
|
128
|
+
| SCRIPT_DYNAMIC_CODE/PRIVILEGE | strip | 80 | `app.trustedFunction`, `beginPriv`, `security` |
|
|
129
|
+
| SCRIPT_DYNAMIC_CODE/COMPUTED_ACCESS | strip | 40 | A property read by a name computed at run time |
|
|
130
|
+
| SCRIPT_DYNAMIC_CODE/PROTOTYPE | strip | 60 | `constructor`, `__proto__`, `prototype`, `Object.defineProperty`, `Proxy`, `Reflect` |
|
|
131
|
+
| SCRIPT_DYNAMIC_CODE/WITH | strip | 40 | A with statement |
|
|
132
|
+
| SCRIPT_DYNAMIC_CODE/SELF_REFERENCE | strip | 60 | `arguments.callee`, a function's own `toString` |
|
|
133
|
+
| SCRIPT_OBFUSCATION/CHAR_CODES | strip | 50 | `String.fromCharCode` |
|
|
134
|
+
| SCRIPT_OBFUSCATION/DECODE | strip | 60 | Decoders, number bases, text hidden among filler, FormCalc's `Decode` |
|
|
135
|
+
| SCRIPT_OBFUSCATION/CONCATENATION | strip | 40 | A string joined from many short pieces |
|
|
136
|
+
| SCRIPT_OBFUSCATION/ESCAPES | strip | 40 | Escapes that write printable ASCII |
|
|
137
|
+
| SCRIPT_OBFUSCATION/ENCODED_STRING | strip | 50 | A long base64, hex or escaped string |
|
|
138
|
+
| SCRIPT_OBFUSCATION/LONG_STRING | strip | 40 | A string over 64 KB, or over 1000 characters with a run of 64 and no whitespace |
|
|
139
|
+
| SCRIPT_OBFUSCATION/NUMBER_EXPRESSION | strip | 30 | Arithmetic on hex constants |
|
|
140
|
+
| SCRIPT_OBFUSCATION/BRACKET_NAME | strip | 30 | A viewer API reached by a quoted name |
|
|
141
|
+
| SCRIPT_API/EXPLOITED | strip | 80 | `getAnnots`, `syncAnnotScan`, `media`, `spell`, signature calls, `xfa.signature`, `util.printf` with a computed format or a field width past 100 |
|
|
142
|
+
| SCRIPT_API/UNKNOWN | strip | 30 | A viewer API or global name that is not form computation |
|
|
143
|
+
| SCRIPT_API/PASSWORD_PROMPT | strip | 50 | `app.response` or `xfa.host.response` with the password option set |
|
|
144
|
+
| SCRIPT_SYNTAX/UNPARSEABLE | strip | 50 | The script is not valid JavaScript, or FormCalc for a FormCalc script |
|
|
145
|
+
| SCRIPT_LIMIT/SIZE | strip | 30 | The script is longer than `limits.scriptSize` |
|
|
146
|
+
| SCRIPT_LIMIT/DEPTH | strip | 30 | The script nests deeper than `limits.depth` |
|
|
147
|
+
|
|
148
|
+
The categories start with `SCRIPT_` so they never read as another package's kinds in a shared report. pdf-defuse has a FORM category of its own.
|
|
149
|
+
|
|
150
|
+
Each finding carries the API in `data.api` where there is one, and its `location` gives the line, after the location the container gave. `allFindingSpecs()` returns this table.
|
|
151
|
+
|
|
152
|
+
## Scoring
|
|
153
|
+
|
|
154
|
+
The score takes the highest weight in full and a quarter of each other kind's weight, adds 20 for obfuscation together with a way out of the form or dynamic code, and 20 for obfuscation together with a viewer API known from exploits, and stops at 100. The risk bands start at 1 for LOW, 25 for MEDIUM, 50 for HIGH and 80 for CRITICAL.
|
|
155
|
+
|
|
156
|
+
## Options
|
|
157
|
+
|
|
158
|
+
| Option | Default | What it does |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `limits.scriptSize` | none | Characters in one script. A longer script is SCRIPT_LIMIT/SIZE and is not parsed |
|
|
161
|
+
| `limits.depth` | none | Nesting of the syntax tree. A deeper script is SCRIPT_LIMIT/DEPTH |
|
|
162
|
+
| `actionOverrides` | none | `[{ category, detail?, action }]` sets a kind to `strip` or `info`. An `info` kind no longer makes a script unsafe |
|
|
163
|
+
| `scoreWeights` | none | `[{ category, detail?, weight }]` |
|
|
164
|
+
| `scoreBands` | above | `{ low, medium, high, critical }` |
|
|
165
|
+
| `context` | none | `inspectScript` only. `{ trigger, location, fieldName, language, form }`. The first three go into the findings' locations. `language: 'formcalc'` reads FormCalc, and `form: 'xfa'` reads JavaScript against the XFA object model |
|
|
166
|
+
| `knownGlobals` | none | `inspectScript` only. Global names scripts that ran before defined and kept |
|
|
167
|
+
|
|
168
|
+
A limit left out is not enforced. `formScriptPlugin(options)` takes the same options but `context` and `knownGlobals`, which it fills in itself, and keeps every decision it made in `plugin.decisions`.
|
|
169
|
+
|
|
170
|
+
## What it does not protect against
|
|
171
|
+
|
|
172
|
+
- A form script can loop forever. script-defuse does not decide whether a loop ends.
|
|
173
|
+
- pdf-defuse removes an XFA form's submits, connections, unsafe links and the images no plugin keeps. script-defuse sees only the scripts it is handed.
|
|
174
|
+
- A form that uses a viewer API outside the allowlist loses that script. An action override keeps a kind you accept.
|
|
175
|
+
- JavaScript files, and scripts in HTML, are not covered yet.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
npm install
|
|
181
|
+
npm test
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`npm test` runs the linter, builds, and runs the tests. The tests build each script they check. No sample from a real form or a malicious file goes in this repository.
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ScriptContext, type ScriptFinding, type ScriptOptions } from './types';
|
|
2
|
+
/** Functions a script defines at its top level, and a script run after it may call. */
|
|
3
|
+
export interface AnalyzeInput {
|
|
4
|
+
text: string;
|
|
5
|
+
context?: ScriptContext;
|
|
6
|
+
options?: ScriptOptions;
|
|
7
|
+
/** Global names earlier scripts of the same document defined, and that were kept. */
|
|
8
|
+
known?: ReadonlySet<string>;
|
|
9
|
+
}
|
|
10
|
+
export interface AnalyzeResult {
|
|
11
|
+
findings: ScriptFinding[];
|
|
12
|
+
/** Global names the script defines: functions, top-level variables, and properties it sets on `this`. */
|
|
13
|
+
definedGlobals: string[];
|
|
14
|
+
}
|
|
15
|
+
/** A string of base64, hex, or %u and \x escapes long enough to carry a payload. */
|
|
16
|
+
export declare const ENCODED: RegExp[];
|
|
17
|
+
export declare function analyzeScript(input: AnalyzeInput): AnalyzeResult;
|
|
18
|
+
/**
|
|
19
|
+
* A string over 64KB, or over 1000 characters with a stretch of 64 or more that has no whitespace: a payload or an
|
|
20
|
+
* encoded blob. Text and tables of numbers break into words; the forms sampled never ran past 18.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isLongBlob(s: string): boolean;
|