Skip to content

Commit b6d2474

Browse files
authored
feat: implement pipelines with built-in deobfuscate (#398)
Apply suggestion from @Copilot Co-authored-by: Copilot <[email protected]> chore: add changeset
1 parent 14cb982 commit b6d2474

File tree

9 files changed

+231
-9
lines changed

9 files changed

+231
-9
lines changed

.changeset/grumpy-insects-love.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nodesecure/js-x-ray": minor
3+
---
4+
5+
Implement new pipeline mechanism with a built-in deobfuscate

workspaces/js-x-ray/docs/AstAnalyser.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ interface AstAnalyserOptions {
3232
* @default false
3333
*/
3434
optionalWarnings?: boolean | Iterable<OptionalWarningName>;
35+
pipelines?: Pipeline[];
3536
}
3637
```
3738

workspaces/js-x-ray/src/AstAnalyser.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@ import {
1717
SourceFile,
1818
type SourceFlags
1919
} from "./SourceFile.js";
20-
import { isOneLineExpressionExport } from "./utils/index.js";
2120
import { JsSourceParser, type SourceParser } from "./JsSourceParser.js";
2221
import { ProbeRunner, type Probe } from "./ProbeRunner.js";
2322
import { walkEnter } from "./walker/index.js";
2423
import * as trojan from "./obfuscators/trojan-source.js";
24+
import {
25+
isOneLineExpressionExport
26+
} from "./utils/index.js";
27+
import {
28+
PipelineRunner,
29+
type Pipeline
30+
} from "./pipelines/index.js";
2531

2632
export interface Dependency {
2733
unsafe: boolean;
@@ -85,23 +91,27 @@ export interface AstAnalyserOptions {
8591
* @default false
8692
*/
8793
optionalWarnings?: boolean | Iterable<OptionalWarningName>;
94+
pipelines?: Pipeline[];
8895
}
8996

9097
export interface PrepareSourceOptions {
9198
removeHTMLComments?: boolean;
9299
}
93100

94101
export class AstAnalyser {
102+
#pipelineRunner: PipelineRunner;
95103
parser: SourceParser;
96104
probes: Probe[];
97105

98106
constructor(options: AstAnalyserOptions = {}) {
99107
const {
100108
customProbes = [],
101109
optionalWarnings = false,
102-
skipDefaultProbes = false
110+
skipDefaultProbes = false,
111+
pipelines = []
103112
} = options;
104113

114+
this.#pipelineRunner = new PipelineRunner(pipelines);
105115
this.parser = options.customParser ?? new JsSourceParser();
106116

107117
let probes = ProbeRunner.Defaults;
@@ -144,15 +154,15 @@ export class AstAnalyser {
144154
const body = this.parser.parse(this.prepareSource(str, { removeHTMLComments }), {
145155
isEcmaScriptModule: Boolean(module)
146156
});
157+
147158
const source = new SourceFile();
148159
if (trojan.verify(str)) {
149160
source.warnings.push(
150161
generateWarning("obfuscated-code", { value: "trojan-source" })
151162
);
152163
}
153164

154-
const runner = new ProbeRunner(source, this.probes);
155-
165+
const probeRunner = new ProbeRunner(source, this.probes);
156166
if (initialize) {
157167
if (typeof initialize !== "function") {
158168
throw new TypeError("options.initialize must be a function");
@@ -161,14 +171,15 @@ export class AstAnalyser {
161171
}
162172

163173
// we walk each AST Nodes, this is a purely synchronous I/O
164-
walkEnter(body, function walk(node) {
174+
const reducedBody = this.#pipelineRunner.reduce(body);
175+
walkEnter(reducedBody, function walk(node) {
165176
// Skip the root of the AST.
166177
if (Array.isArray(node)) {
167178
return;
168179
}
169180

170181
source.walk(node);
171-
const action = runner.walk(node);
182+
const action = probeRunner.walk(node);
172183
if (action === "skip") {
173184
this.skip();
174185
}
@@ -180,7 +191,7 @@ export class AstAnalyser {
180191
}
181192
finalize(source);
182193
}
183-
runner.finalize();
194+
probeRunner.finalize();
184195

185196
// Add oneline-require flag if this is a one-line require expression
186197
if (isOneLineExpressionExport(body)) {

workspaces/js-x-ray/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ export * from "./JsSourceParser.js";
33
export * from "./AstAnalyser.js";
44
export * from "./EntryFilesAnalyser.js";
55
export * from "./SourceFile.js";
6+
export {
7+
Pipelines,
8+
type Pipeline
9+
} from "./pipelines/index.js";
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Import Third-party Dependencies
2+
import type { ESTree } from "meriyah";
3+
4+
export interface Pipeline {
5+
name: string;
6+
7+
walk(
8+
body: ESTree.Program["body"]
9+
): ESTree.Program["body"];
10+
}
11+
12+
export class PipelineRunner {
13+
#pipelines: Pipeline[];
14+
15+
constructor(
16+
pipelines: Pipeline[]
17+
) {
18+
this.#pipelines = removeDuplicatedPipelines(pipelines);
19+
}
20+
21+
reduce(
22+
initialBody: ESTree.Program["body"]
23+
): ESTree.Program["body"] {
24+
return this.#pipelines.reduce(
25+
(body, pipeline) => pipeline.walk(body),
26+
initialBody
27+
);
28+
}
29+
}
30+
31+
function removeDuplicatedPipelines(
32+
pipelines: Pipeline[]
33+
): Pipeline[] {
34+
const seen = new Set<string>();
35+
36+
return pipelines.filter((pipeline) => {
37+
if (seen.has(pipeline.name)) {
38+
return false;
39+
}
40+
seen.add(pipeline.name);
41+
42+
return true;
43+
});
44+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Import Third-party Dependencies
2+
import type { ESTree } from "meriyah";
3+
import { match } from "ts-pattern";
4+
import { joinArrayExpression } from "@nodesecure/estree-ast-utils";
5+
6+
// Import Internal Dependencies
7+
import { walkEnter } from "../walker/index.js";
8+
import type { Pipeline } from "./Runner.class.js";
9+
10+
export class Deobfuscate implements Pipeline {
11+
name = "deobfuscate";
12+
13+
#withCallExpression(
14+
node: ESTree.CallExpression
15+
): ESTree.Node | void {
16+
const value = joinArrayExpression(node);
17+
if (value !== null) {
18+
return {
19+
type: "Literal",
20+
value,
21+
raw: value
22+
};
23+
}
24+
25+
return void 0;
26+
}
27+
28+
walk(
29+
body: ESTree.Program["body"]
30+
): ESTree.Program["body"] {
31+
const self = this;
32+
walkEnter(body, function walk(node): void {
33+
if (Array.isArray(node)) {
34+
return;
35+
}
36+
37+
match(node)
38+
.with({ type: "CallExpression" }, (node) => {
39+
this.replaceAndSkip(self.#withCallExpression(node));
40+
})
41+
.otherwise(() => void 0);
42+
});
43+
44+
return body;
45+
}
46+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Import Internal Dependencies
2+
import { Deobfuscate } from "./deobfuscate.js";
3+
import {
4+
PipelineRunner,
5+
type Pipeline
6+
} from "./Runner.class.js";
7+
8+
export const Pipelines = Object.freeze({
9+
deobfuscate: Deobfuscate
10+
}) satisfies Record<string, new() => Pipeline>;
11+
12+
export { PipelineRunner };
13+
export type { Pipeline };

workspaces/js-x-ray/src/walker/walker.base.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import type { ESTree } from "meriyah";
44
export interface WalkerContext {
55
skip: () => void;
66
remove: () => void;
7-
replace: (node: ESTree.Node) => void;
7+
replace: (node: ESTree.Node | void) => void;
8+
replaceAndSkip: (node: ESTree.Node | void) => void;
89
}
910

1011
export class WalkerBase {
@@ -17,7 +18,17 @@ export class WalkerBase {
1718
this.context = {
1819
skip: () => (this.should_skip = true),
1920
remove: () => (this.should_remove = true),
20-
replace: (node) => (this.replacement = node)
21+
replace: (node) => {
22+
if (node !== undefined) {
23+
this.replacement = node;
24+
}
25+
},
26+
replaceAndSkip: (node) => {
27+
this.should_skip = true;
28+
if (node !== undefined) {
29+
this.replacement = node;
30+
}
31+
}
2132
};
2233
}
2334

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Import Node.js Dependencies
2+
import { describe, mock, test } from "node:test";
3+
import assert from "node:assert";
4+
5+
// Import Internal Dependencies
6+
import {
7+
AstAnalyser,
8+
JsSourceParser,
9+
Pipelines
10+
} from "../src/index.js";
11+
import {
12+
getWarningKind
13+
} from "./utils/index.js";
14+
15+
describe("AstAnalyser pipelines", () => {
16+
test("should iterate once on the pipeline", () => {
17+
const pipeline = {
18+
name: "test-pipeline",
19+
walk: mock.fn((body) => body)
20+
};
21+
22+
const analyser = new AstAnalyser({
23+
customParser: new JsSourceParser(),
24+
pipelines: [
25+
pipeline,
26+
pipeline
27+
]
28+
});
29+
30+
analyser.analyse(`return "Hello World";`);
31+
32+
assert.strictEqual(pipeline.walk.mock.callCount(), 1);
33+
assert.deepEqual(
34+
pipeline.walk.mock.calls[0].arguments[0],
35+
[
36+
{
37+
type: "ReturnStatement",
38+
argument: {
39+
type: "Literal",
40+
value: "Hello World",
41+
raw: "\"Hello World\"",
42+
loc: {
43+
start: {
44+
line: 1,
45+
column: 7
46+
},
47+
end: {
48+
line: 1,
49+
column: 20
50+
}
51+
}
52+
},
53+
loc: {
54+
start: {
55+
line: 1,
56+
column: 0
57+
},
58+
end: {
59+
line: 1,
60+
column: 21
61+
}
62+
}
63+
}
64+
]
65+
);
66+
});
67+
});
68+
69+
describe("Pipelines.deobfuscate", () => {
70+
test("should find a shady-url by deobfuscating a joined ArrayExpression", () => {
71+
const analyser = new AstAnalyser({
72+
customParser: new JsSourceParser(),
73+
pipelines: [
74+
new Pipelines.deobfuscate()
75+
]
76+
});
77+
78+
const { warnings } = analyser.analyse(`
79+
const URL = ["http://", ["77", "244", "210", "1"].join("."), "/script"].join("");
80+
`);
81+
82+
assert.deepEqual(
83+
getWarningKind(warnings),
84+
["shady-link"].sort()
85+
);
86+
});
87+
});

0 commit comments

Comments
 (0)