JS Forensics is a static-analysis toolkit for JavaScript that is minified, obfuscated, or simply unfamiliar. It parses a file, reports what is in it, and applies conservative, traceable transformations that undo common obfuscation patterns when it can prove the rewrite is safe.
It never executes the file it analyzes and never contacts a URL it finds.
Status: v0.2.0, alpha. Every transformation checks documented preconditions and skips, with a reason, anything it cannot prove. The output is checked for valid syntax, which is not proof that it behaves the same. See Known limitations before relying on a result.
- Parses modern JavaScript with tree-sitter and reports syntax errors with line and column.
- Resolves scopes: which declaration every name refers to, accounting for hoisting, block scoping, shadowing, and parameters.
- Recognises string-array obfuscation (the provider, rotation, decoder, aliases, and wrapper functions that tools such as javascript-obfuscator generate), computes the rotation statically, and replaces each lookup with the string it returns.
- Propagates constants, inlines constant object and array members and
one-line proxy functions, folds constant expressions, simplifies constant
conditions, removes branches that can never run, and restores code flattened
into a
while/switchdispatch loop. - Builds a function call graph, with the browser APIs, endpoints, and decoders used in each function, and lists calls it cannot resolve.
- Detects twelve documented obfuscation indicators with evidence, extracts and classifies strings, lists browser API references and potential network endpoints.
- Writes a terminal summary, JSON, a standalone HTML report, a unified diff, and Graphviz DOT for the call graph.
- Scans a folder of
.js/.mjs/.cjsfiles and maps relative imports between them.
Python 3.10 to 3.14 is supported.
$ pip install js-forensics
$ js-forensics --version
js-forensics 0.2.0To install from source instead:
$ git clone https://github.com/stackadnan/js-forensics.git
$ cd js-forensics
$ python -m venv .venv && source .venv/bin/activate
$ pip install .The Quick start examples use files in the repository's examples/ folder.
The repository ships two obfuscated files to try things on:
examples/sample.js (hand-written) and examples/obfuscated.js (output of
javascript-obfuscator 4.2.2 with string arrays and control-flow flattening).
$ js-forensics analyze examples/sample.js
$ js-forensics analyze examples/sample.js --format json --output report.json
$ js-forensics strings examples/sample.js
$ js-forensics endpoints examples/sample.js
$ js-forensics deobfuscate examples/sample.js --output-dir output/
$ js-forensics report examples/sample.js --format html --output report.html
$ js-forensics diff examples/obfuscated.js
$ js-forensics callgraph examples/obfuscated.js --format dot --output calls.dot
$ js-forensics explain examples/obfuscated.js --line 70 --column 34
$ js-forensics passesdeobfuscate turns the 91-line examples/obfuscated.js (a 28-entry
string array behind a checksum rotation, an alias-based decoder, a proxy
object, and a flattened switch loop) into:
function greet(_0x128779) {
console.log("Hello, " + _0x128779);
fetch("https://api.example.com/v1/users", { method: "POST" });
return _0x128779.length;
}
greet("world"), console.log("done", greet("again"));and reports what each pass did:
Pass Changes Skipped Status
format-statements 45 0 changed in round 1, 2, 3
simplify-string-escapes 1 0 changed in round 1
resolve-string-arrays 20 0 changed in round 1, 2
decode-static-calls 0 0 no change
inline-object-literals 6 0 changed in round 1, 2
propagate-constants 2 0 changed in round 2
fold-constants 2 0 changed in round 1
simplify-conditionals 0 0 no change
remove-dead-branches 0 0 no change
unflatten-control-flow 2 0 changed in round 1
remove-unused-constants 5 0 changed in round 1, 2
simplify-property-access 15 0 changed in round 1
normalize-numbers 26 0 changed in round 1
4 round(s). Each accepted stage was parsed again without syntax errors. This checks syntax only; it
does not prove the program behaves the same.
Every change is recorded with its location and a reason in
output/transformations.json, and the source after each changing stage is
kept under output/stages/. The names stay machine-generated: JS Forensics
does not guess identifier names.
examples/sample.js becomes:
function _0x2b7c(_0x1d){
return "https://api.example.com/v1/session?token=" + _0x1d
}
fetch(_0x2b7c("abc"),{method:"POST",keepalive:true});
setTimeout(function(){
console.log("done",60000)
},1000);explain shows everything known about a position, including what a
string-array call returns:
examples/obfuscated.js, line 70, column 34
}, _0x214be7 = _0x7dd790[_0x5e13b6(0x1b6)][_0x5e13b6(0x1b9)]('|'), _0x25262a = 0x0;
Inside: greet@58:1
Kind From To Finding
call 70:34 70:50 call of _0x1269@2:1 (resolved)
string-array-call 70:34 70:50 _0x2c89 returns 'QKgfd'
| Command | Purpose |
|---|---|
analyze FILE |
Summary: statistics, indicators, string arrays, functions, strings, APIs, endpoints. --format text|json. |
strings FILE |
String and template literals with categories. --category filters. |
endpoints FILE |
Potential network endpoints, domains, and request calls whose URL is dynamic. |
deobfuscate FILE |
Run the passes and write the result to --output-dir (default js-forensics-output/). |
diff FILE |
Unified diff between the file and its transformed version. Writes nothing unless --output is given. |
passes |
List the passes, their default order, and their preconditions. |
callgraph FILE |
Functions, calls between them, and what each uses. --format text|json|dot. |
explain FILE --line N [--column N] |
Every finding at a source position. |
project DIRECTORY |
Analyze every .js/.mjs/.cjs file in a folder and map relative imports. |
report FILE |
Standalone report. --format html|json; --transformations adds the pipeline history. |
deobfuscate and diff take --passes a,b,c (run exactly these, in this
order), --skip a,b, and --max-rounds N. Analysis commands take
--output/-o, --tolerant (analyze despite syntax errors and mark results as
partial), --no-redact, and --max-bytes (default 2 MiB).
Exit codes: 0 success, 2 invalid command line, 3 input missing,
unreadable, too large, or not UTF-8, 4 JavaScript syntax error, 5 output
could not be written. 1 is an unexpected internal error.
docs/usage.md is the full reference. docs/transformations.md documents every pass: what it recognises, its preconditions, and what it leaves alone. docs/output-schema.md describes the JSON.
Passes run in a fixed default order, and the sequence repeats until a round changes nothing (at most six rounds), because one pass often enables another: a resolved string array exposes constants to propagate, which exposes a dispatch order string to unflatten.
| Pass | Undoes |
|---|---|
format-statements |
Minified layout (whitespace between statements only). |
simplify-string-escapes |
"\x68\x69" → "hi". |
resolve-string-arrays |
String-array lookups such as _0x1269(0x1a8). |
decode-static-calls |
atob, String.fromCharCode, String.fromCodePoint, decodeURIComponent, decodeURI, unescape on constants. |
inline-object-literals |
O.key and O.add(a, b) on objects and arrays that never change. |
propagate-constants |
Variables that only ever hold one literal. |
fold-constants |
0x3e8 * 0x3c, !![], typeof "s", x + "a" + "b". |
simplify-conditionals |
true ? a : b, 1 && x, 0 || x. |
remove-dead-branches |
if (false) {...} and statements after return. |
unflatten-control-flow |
while (true) switch (order[i++]) dispatch loops. |
remove-unused-constants |
Declarations nothing reads after the above. |
simplify-property-access |
o["name"] → o.name. |
normalize-numbers |
0x1a → 26. |
What is supported, what is heuristic, and what stays unresolved:
- Proven rewrites. Every pass above changes code only when its documented preconditions hold, using scope analysis to rule out shadowing and reassignment. If a precondition fails, the code is left alone and the reason is recorded.
- Recognised shapes. String arrays are resolved for the provider,
decoder, alias, wrapper, and rotation shapes listed in
docs/transformations.md, which cover javascript-obfuscator 4.x and 5.x output without string encoding. Dispatch loops are unflattened only in the exact shape described there. - Heuristic, not transformed. The obfuscation indicators and score, string categories, credential redaction, and browser API confidence levels.
- Unresolved. String arrays with base64 or RC4 encoding, values computed
at runtime, code inside
eval/Function, and any shape not listed are reported but not changed.
Transformations replace exact source ranges; untouched code stays byte-for-byte as written. After every stage the result is parsed again; a stage whose output does not parse, or that grows the file more than fourfold, is discarded and reported as reverted.
During development, the default pipeline was run on javascript-obfuscator
4.2.2 and 5.9.0 output of the test suite's synthetic programs across several
option presets and seeds (162 runs). Each original and result was executed in
an isolated Node.js context, using only those trusted test programs, and
logged the same calls. The obfuscator samples in tests/fixtures/obfuscator/
are checked the same way in the test suite when Node.js is installed. This is
evidence for the tested shapes, not a guarantee for other input.
| Area | How it works |
|---|---|
| Statistics | Counts of syntax-node types. |
| Scope analysis | Lexical scopes, hoisting, and every name resolved to its declaration. Files using with or opaque direct eval are reported as unreliable and not transformed. |
| Obfuscation indicators | Twelve structural rules with fixed thresholds and weights. The score is the capped sum of the weights that triggered. |
| String classification | Pattern heuristics. Ambiguous strings stay other. |
| Static evaluation | Reimplementations of string escapes, number formatting, parseInt, atob, URI decoding, and a subset of operators, applied only to constants. |
| Call graph | Calls resolved through scopes; everything else is labelled external or unresolved. |
| Browser APIs | A catalogue of global names and member chains, plus distinctive method names at lower confidence. |
| Endpoints | Arguments of fetch, XMLHttpRequest.open, WebSocket, EventSource, navigator.sendBeacon, axios, and jQuery, plus URL-like strings and constant expressions. |
| Project scan | Per-file analysis plus relative import, export … from, import(), and require() resolution within the folder. |
Every input is treated as hostile.
- The analyzed file is read and parsed. It is never evaluated, imported, or
passed to a JavaScript runtime. Code strings passed to
Functionorevalare parsed to check whether they could reassign variables; they are never run. - No network access happens. URLs are extracted as text. The test suite fails if a socket is opened.
- The original file is never modified. Outputs go to a separate path and a write that would land on the input is refused.
projectnever follows symbolic links and never reads outside the given folder.- Input size is limited (2 MiB per file by default), nesting depth is bounded in the evaluators, the pipeline stops after six rounds, and a stage that would grow the file more than fourfold is reverted.
- HTML reports contain no script, carry a
default-src 'none'Content-Security-Policy, and escape all text taken from the file, including strings that only appear after deobfuscation. URLs are shown as text, not links. - Terminal and HTML output escape control characters, so a file cannot inject ANSI sequences or bidirectional overrides into what you read.
- Likely credentials are masked in reports (pattern matching; it will miss
some).
--no-redactturns it off. The deobfuscated JavaScript itself is never redacted. - A finding describes source text. A listed endpoint or API reference does not mean the code runs.
- Values are only resolved when they are constant: literals, variables that only ever hold one literal, constant object members, and recognised string-array calls. Strings assembled from runtime data are not recovered.
- String arrays with base64 or RC4 encoding are reported as unsupported. Other obfuscators' string tables are resolved only if they happen to match the documented shapes.
- Control-flow unflattening handles only the
while/switchdispatch shape. Other flattening schemes, opaque predicates that depend on runtime values, and dead-code injection that does not reduce to a constant condition are left as they are. - No renaming of identifiers.
- Program-level declarations are only removed when their names look
machine-generated (
_0x…), since other scripts on a page can use a script's globals. The analysis assumes that no other script reassigns this file's global variables. - Built-ins (
String,atob,parseInt,String.prototype.split) are assumed not to be replaced at runtime. - The call graph does not resolve method calls, callbacks, or calls through object properties and parameters.
projectresolves relative imports only; packages, path aliases, and TypeScript are not supported.- The parser is tree-sitter's JavaScript grammar. It also accepts JSX and does not report every early error an engine would. TypeScript is not supported.
- Only UTF-8 input is accepted.
- Memory and time grow with input size. On the development machine, a full analysis with transformations of 1 MB of dense minified source took about 23 seconds and peaked near 700 MB, which is why the default limit is 2 MiB.
See CONTRIBUTING.md for setup, the checks to run, and the rules for adding detectors and transformations. docs/architecture.md explains how the code is laid out.
MIT. See LICENSE.
Security problems should be reported privately; see SECURITY.md.
