@patrickdk77/script-defuse
v0.3.1
Published
Inspect document scripts: PDF form scripts in JavaScript and FormCalc, from AcroForm and XFA forms, and the VBA modules of Office documents. Keeps a form script only when every call and property it uses formats, checks or calculates form fields, and a mac
Downloads
911
Maintainers
Readme
script-defuse
Tell a document's harmless scripts from the rest. script-defuse parses a PDF form's 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. It also reads the VBA modules of Office documents, and passes a module that calls nothing on its list of the ways a macro reaches outside its document. 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 or file, the network, a program, code built from a string, or text it hides.
It plugs into pdf-defuse and office-defuse as a script plugin. A fillable form keeps its calculations, formats and checks, and a workbook keeps the macros that only work on the workbook, while every other script goes. It parses JavaScript with acorn, its one runtime dependency, and follows the shapes of the other defuse packages.
npm install @patrickdk77/script-defuseNode 18 or later.
Quick start
const { inspectScript } = require('@patrickdk77/script-defuse');
const r = inspectScript('event.value = this.getField("Qty").value * this.getField("Price").value;');
r.verdict; // 'form' or 'unsafe'
r.score; // 0 to 100
r.risk; // 'NONE', 'LOW', 'MEDIUM', 'HIGH' or 'CRITICAL'
r.findings; // [{ category, detail, description, action, location, data, weight }]ES modules work too: import { inspectScript } from '@patrickdk77/script-defuse'.
With pdf-defuse
const { disarmPdf } = require('@patrickdk77/pdf-defuse');
const { formScriptPlugin } = require('@patrickdk77/script-defuse');
const result = await disarmPdf(bytes, { scriptPlugins: [formScriptPlugin()] });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.
The plugin's answer is final, and its findings are what the script scores. Each finding's weight is what it adds to that script's score from inspectScript: a finding that strips weighs what scoreWeights or the table gives it, and one that only describes the script weighs 0. A form script the plugin keeps therefore adds nothing to the PDF's score from pdf-defuse 0.3.0 and office-defuse 0.3.0. Earlier versions scored a kept script at their own weight for it.
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.
With file-defuse 0.1.4 or later, every PDF and Office file already gets one.
With office-defuse
const { disarmOffice } = require('@patrickdk77/office-defuse');
const { formScriptPlugin } = require('@patrickdk77/script-defuse');
const result = await disarmOffice(bytes, { scriptPlugins: [formScriptPlugin()] });office-defuse 0.2.3 and later shows the plugin each module of a VBA project and keeps the project only when every module passes. It then writes the project without its compiled code, so Office compiles the source script-defuse read. See VBA.
What a form script may use
An allowlist decides. A script that uses anything not on it is unsafe, a viewer API script-defuse does not know included.
- Fields:
this.getField(name), orgetField(name),event.targetandevent.source, and a field's value, display, colors, font, list items and check state.this.numFields,getNthFieldName,calculateNowandresetForm. - The event:
event.value,change,rc,willCommit,selStart,selEndand the rest of what a format, keystroke, validate or calculate script reads and sets. - Acrobat's form helpers, the AForm functions:
AFNumber_Format,AFSimple_Calculate,AFDate_FormatEx,AFRange_Validate,AFSpecial_Keystrokeand the others. - Formatting:
util.printf,util.printd,util.printxandutil.scand, and thecoloranddisplayconstants. - Messages:
app.alert,app.responseandapp.beep, and readingapp.viewerVersion,viewerType,platformandlanguage. A prompt that asks for a password is SCRIPT_API/PASSWORD_PROMPT, since a form takes a password in a field of its own. - The document's
nocache,noautocomplete,zoom,zoomType,layout,delayandpageNum, which a script may set, andscroll. this.xfa, orxfaalone, 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.- The language:
Math,Number,String,Date,RegExp,JSON, arrays and objects, and the script's own functions and variables.
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.
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.
XFA forms
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.
XFA JavaScript runs against the XFA object model, with Acrobat's objects beside it.
thisis the node that holds the script, and a bare name is a node the SOM finds, such asTotal.rawValueorform1.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.xfa.resolveNodeandresolveNodesfind nodes by SOM path, computed or not.xfa.form,template,data,datasetsandrecordare trees of nodes.xfa.hostgives message boxes, focus, pages,resetDataand version checks, andxfa.layoutgives pages and positions.eventandxfa.eventgive the XFA event's members, such aschange,newTextandprevText.event.targetis the Acrobat document.- Forms often name a script object
utilorcolor. A name Acrobat'sutilorcolorlacks is then a function of the script object. xfa.datasets.createNode,loadXMLandassignNodeon the data, andXMLData.parsewith entities turned off, build and read data.
These are removed wherever a node, a parameter or a variable reaches them:
xfa.host.gotoURL,exportData,importDataandprint.xfa.connectionSet,xfa.sourceSetand anyexecute, which call a web service or a database.xfa.signature.- A node created with a type that carries script, actions or outside content, such as
createNode("script"), or with a type the script computes. loadXMLandassignNodeon anything but the data, since they can add script to the form.applyXSL, which runs a stylesheet.- Setting a node's
href,usehrefor another attribute that names something outside the form, whether directly or withsetAttribute. XMLData.parsewith entities left on.
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:
Get,PostandPut, which reach a URL, are SCRIPT_EXTERNAL/NETWORK.Evalis SCRIPT_DYNAMIC_CODE/EVAL.Decodeis SCRIPT_OBFUSCATION/DECODE.- A function that FormCalc lacks and the script did not declare is SCRIPT_API/UNKNOWN.
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.
An XFA script defines no globals for other scripts, which reach its functions through the node that holds it.
VBA
context.language: 'vba' reads a VBA module as Office stores it, with form: 'office'. A macro cannot have an allowlist the way a form script does. Its own procedures, the document's sheets and controls, and the whole Office object model are names no list holds. So script-defuse lists the ways out of the document instead, and removes a module that uses one:
- VBA's file statements and functions, such as
Open,Print #,Get,Put,Name,Kill,DirandMkDir.Shell,SendKeys,AppActivateandMacScript.SaveSettingandGetSetting, the DDE functions, andEnviron. Declare,VarPtr,StrPtr,ObjPtrandAddressOf.CreateObject,GetObject, andNewof a library's class.Newof the module's own classes,Collection,DictionaryandRegExpis fine, andNew FileSystemObjectandNew Applicationare not.- Office members that open another file or a URL, send mail, save elsewhere, print, import or export, run code by name or later, or change the VBA project:
Workbooks.Open,FollowHyperlink,Hyperlinks.Add,AttachedTemplate,SendMail,SaveAs,SaveCopyAs,ExportAsFixedFormat,PrintOut,QueryTables,OLEObjects.Add,Application.Runwith an argument,Evaluateof anything but a string,ExecuteExcel4Macro,OnTime,CallByName,WordBasic,VBProject,VBE,AddInsand the others insrc/vba.ts. - A formula string that calls WEBSERVICE, CALL, REGISTER, EXEC, RTD or HYPERLINK, a DDE formula, and a macro name in another file, as
'other.xlsm'!Macro.
A name VBA or Office defines counts wherever it is written, as Shell, VBA.Shell or .Shell inside a With block. A module that declares a variable of the same name does not hide it, since the name means VBA's again in the next procedure.
The project's references matter as much as its calls, since a library can add global functions a list never sees. office-defuse gives them with the project's first module, in context.references. script-defuse accepts VBA, OLE Automation, Office, Excel, Word, PowerPoint, Visio, Publisher, Project, Forms 2.0, the Ref Edit control, the Scripting runtime and VBScript regular expressions, by type library GUID. Any other library, and any project but Word's Normal template, is SCRIPT_EXTERNAL/REFERENCE.
VBA has its own ways to hide text, and each strips the module:
Chrof a printable character six or more times in one statement, asChr(112) & Chr(111). A string literal could hold those characters, so only hiding them explains it.Chrof a control character, the quote or an accented letter is how macros write those, and stays.Chr(Asc(Mid(s, i, 1)) Xor k)and other decoders that rebuild a string a character at a time,StrReverse, andReplaceof filler out of a literal.- Strings that spell a payload word only once they are joined, as
"pow" & "ersh" & "ell", in one statement or appended to one variable across statements. The words are programs, script hosts,http://, file types that run, and the objects that fetch and write files. A joined string is checked for encoded data too. A long message joined from many literals hides nothing and stays.
Obfuscation
A form script has nothing to hide, so each of these strips the script:
String.fromCharCodeandfromCodePoint.unescape,escape,decodeURIComponent,decodeURI,atobandbtoa.- A number turned into text in a base past 10, as
(1525081).toString(36)spells word. replaceorspliton a string the script wrote itself, as"qwqoqrqd".replace(/q/g, "")uncovers hidden text.- A string joined from 6 or more pieces of one or two characters, or from 25 or more pieces.
- Three or more
\xor\uescapes that write printable ASCII. - A string of base64, hex,
%uor\xescapes long enough to carry a payload. - A string over 64 KB, or over 1000 characters with a run of 64 that has no whitespace, and
repeatpast 1000. Text and tables of numbers break into words, so a long message or a CRC table stays. - Arithmetic on three or more number literals with one in hex, such as
0x1a2+0x3b4+-0x556. - A viewer API reached by a quoted name, such as
app["alert"]. - 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.
eval,Function, timers that take a string,arguments.calleeand a function's owntoString.
Findings
| Kind | Action | Weight | When |
|---|---|---|---|
| SCRIPT_FORM/COMPUTATION | info | 0 | The script only formats, checks or calculates form fields |
| SCRIPT_FORM/DOCUMENT_MACRO | info | 0 | The VBA module calls nothing on the list of ways out of its document |
| SCRIPT_EXTERNAL/URL | strip | 60 | app.launchURL, this.getURL, xfa.host.gotoURL, setting a node's href |
| SCRIPT_EXTERNAL/SUBMIT | strip | 50 | this.submitForm |
| SCRIPT_EXTERNAL/MAIL | strip | 50 | this.mailDoc, mailForm, app.mailMsg |
| SCRIPT_EXTERNAL/OPEN_DOCUMENT | strip | 60 | app.openDoc, app.newDoc, app.activeDocs |
| SCRIPT_EXTERNAL/DATA_OBJECT | strip | 70 | exportDataObject, importDataObject and the other attached-file calls |
| 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 |
| SCRIPT_EXTERNAL/NETWORK | strip | 70 | Net, SOAP, Collab, ADBC, RSS, XFA connections and execute, FormCalc's Get, Post and Put |
| SCRIPT_EXTERNAL/MENU_COMMAND | strip | 60 | app.execMenuItem, app.execDialog |
| SCRIPT_EXTERNAL/PRINT | strip | 20 | this.print, xfa.host.print |
| SCRIPT_EXTERNAL/SAVE | strip | 30 | this.saveAs, closeDoc |
| SCRIPT_EXTERNAL/FILE_SYSTEM | strip | 70 | VBA's file statements and functions, FileSystemObject |
| SCRIPT_EXTERNAL/PROCESS | strip | 90 | Shell, SendKeys, AppActivate, MacScript, New Application, a formula that calls EXEC |
| SCRIPT_EXTERNAL/REGISTRY | strip | 60 | SaveSetting, GetSetting, PrivateProfileString |
| SCRIPT_EXTERNAL/DDE | strip | 80 | DDEInitiate and the other DDE functions, a DDE formula |
| SCRIPT_EXTERNAL/COM_OBJECT | strip | 80 | CreateObject, GetObject, New of a library's class |
| SCRIPT_EXTERNAL/NATIVE_CODE | strip | 90 | Declare, RegisterXLL, a formula that calls CALL or REGISTER |
| SCRIPT_EXTERNAL/ENVIRONMENT | strip | 30 | Environ, Command |
| SCRIPT_EXTERNAL/REFERENCE | strip | 60 | A VBA project's reference to a library or project outside the list |
| SCRIPT_DYNAMIC_CODE/EVAL | strip | 80 | eval, Function, import(), FormCalc's Eval, applyXSL |
| SCRIPT_DYNAMIC_CODE/TIMER | strip | 70 | app.setTimeOut, setInterval |
| 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 |
| SCRIPT_DYNAMIC_CODE/PRIVILEGE | strip | 80 | app.trustedFunction, beginPriv, security |
| SCRIPT_DYNAMIC_CODE/COMPUTED_ACCESS | strip | 40 | A property read by a name computed at run time |
| SCRIPT_DYNAMIC_CODE/PROTOTYPE | strip | 60 | constructor, __proto__, prototype, Object.defineProperty, Proxy, Reflect |
| SCRIPT_DYNAMIC_CODE/WITH | strip | 40 | A with statement |
| SCRIPT_DYNAMIC_CODE/SELF_REFERENCE | strip | 60 | arguments.callee, a function's own toString |
| SCRIPT_DYNAMIC_CODE/LEGACY_MACRO | strip | 80 | WordBasic |
| SCRIPT_OBFUSCATION/CHAR_CODES | strip | 50 | String.fromCharCode |
| SCRIPT_OBFUSCATION/DECODE | strip | 60 | Decoders, number bases, text hidden among filler, FormCalc's Decode |
| SCRIPT_OBFUSCATION/CONCATENATION | strip | 40 | A string joined from many short pieces |
| SCRIPT_OBFUSCATION/ESCAPES | strip | 40 | Escapes that write printable ASCII |
| SCRIPT_OBFUSCATION/ENCODED_STRING | strip | 50 | A long base64, hex or escaped string |
| SCRIPT_OBFUSCATION/LONG_STRING | strip | 40 | A string over 64 KB, or over 1000 characters with a run of 64 and no whitespace |
| SCRIPT_OBFUSCATION/NUMBER_EXPRESSION | strip | 30 | Arithmetic on hex constants |
| SCRIPT_OBFUSCATION/BRACKET_NAME | strip | 30 | A viewer API reached by a quoted name |
| 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 |
| SCRIPT_API/UNKNOWN | strip | 30 | A viewer API or global name that is not form computation |
| SCRIPT_API/PASSWORD_PROMPT | strip | 50 | app.response or xfa.host.response with the password option set |
| SCRIPT_SYNTAX/UNPARSEABLE | strip | 50 | The script does not parse as JavaScript, as FormCalc for a FormCalc script, or as VBA for a VBA module |
| SCRIPT_LIMIT/SIZE | strip | 30 | The script is longer than limits.scriptSize |
| SCRIPT_LIMIT/DEPTH | strip | 30 | The script nests deeper than limits.depth |
The rows above name the PDF APIs. VBA's calls go to the same kinds where they fit: Workbooks.Open and AttachedTemplate are SCRIPT_EXTERNAL/OPEN_DOCUMENT, FollowHyperlink SCRIPT_EXTERNAL/URL, Application.Run and Evaluate SCRIPT_DYNAMIC_CODE/EVAL, VBProject SCRIPT_DYNAMIC_CODE/INSTALL_SCRIPT.
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.
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.
Scoring
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.
Options
| Option | Default | What it does |
|---|---|---|
| limits.scriptSize | none | Characters in one script. A longer script is SCRIPT_LIMIT/SIZE and is not parsed |
| limits.depth | none | Nesting of the syntax tree. A deeper script is SCRIPT_LIMIT/DEPTH |
| actionOverrides | none | [{ category, detail?, action }] sets a kind to strip or info. An info kind no longer makes a script unsafe |
| scoreWeights | none | [{ category, detail?, weight }] |
| scoreBands | above | { low, medium, high, critical } |
| context | none | inspectScript only. { trigger, location, fieldName, language, form, references }. location, or trigger without it, goes into the findings' locations. language: 'formcalc' reads FormCalc, language: 'vba' reads a VBA module, and form: 'xfa' reads JavaScript against the XFA object model. references are a VBA project's, as { name, kind, libid } |
| knownGlobals | none | inspectScript only. Global names scripts that ran before defined and kept |
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.
What it does not protect against
- A form script can loop forever. script-defuse does not decide whether a loop ends.
- 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.
- A form that uses a viewer API outside the allowlist loses that script. An action override keeps a kind you accept.
- In VBA, the list of ways out is a deny list. An Office member that reaches outside the document and is not on it gets through. script-defuse checks the project's references and the classes a module creates, which keeps a library from adding ways the list does not know.
- A macro that only works on its document can still change or delete what the document holds.
- JavaScript files, and scripts in HTML, are not covered yet.
Development
npm install
npm testnpm test runs the linter, builds, and runs the tests. The tests build each script and module they check. No sample from a real form, document or malicious file goes in this repository.
License
MIT
