Skip to content

Commit edd13dc

Browse files
committed
feat: implement pipelines with built-in deobfuscate
Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> chore: add changeset
1 parent 283d5b6 commit edd13dc

File tree

7 files changed

+194
-3
lines changed

7 files changed

+194
-3
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": patch
3+
---
4+
5+
Implement new pipeline mechanism with a built-in deobfuscate

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

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ 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 type { Pipeline } from "./pipelines/pipeline.js";
2528

2629
export interface Dependency {
2730
unsafe: boolean;
@@ -85,6 +88,7 @@ export interface AstAnalyserOptions {
8588
* @default false
8689
*/
8790
optionalWarnings?: boolean | Iterable<OptionalWarningName>;
91+
pipelines?: Pipeline[];
8892
}
8993

9094
export interface PrepareSourceOptions {
@@ -94,14 +98,17 @@ export interface PrepareSourceOptions {
9498
export class AstAnalyser {
9599
parser: SourceParser;
96100
probes: Probe[];
101+
pipelines: Pipeline[] = [];
97102

98103
constructor(options: AstAnalyserOptions = {}) {
99104
const {
100105
customProbes = [],
101106
optionalWarnings = false,
102-
skipDefaultProbes = false
107+
skipDefaultProbes = false,
108+
pipelines = []
103109
} = options;
104110

111+
this.pipelines = pipelines;
105112
this.parser = options.customParser ?? new JsSourceParser();
106113

107114
let probes = ProbeRunner.Defaults;
@@ -129,6 +136,24 @@ export class AstAnalyser {
129136
this.probes = probes;
130137
}
131138

139+
#runPipelines(
140+
body: ESTree.Program["body"]
141+
): ESTree.Program["body"] {
142+
const runnedPipelines = new Set<string>();
143+
let updatedBody = body;
144+
145+
for (const pipeline of this.pipelines) {
146+
if (runnedPipelines.has(pipeline.name)) {
147+
continue;
148+
}
149+
150+
updatedBody = pipeline.walk(updatedBody);
151+
runnedPipelines.add(pipeline.name);
152+
}
153+
154+
return updatedBody;
155+
}
156+
132157
analyse(
133158
str: string,
134159
options: RuntimeOptions = {}
@@ -144,6 +169,8 @@ export class AstAnalyser {
144169
const body = this.parser.parse(this.prepareSource(str, { removeHTMLComments }), {
145170
isEcmaScriptModule: Boolean(module)
146171
});
172+
173+
const updatedBody = this.#runPipelines(body);
147174
const source = new SourceFile();
148175
if (trojan.verify(str)) {
149176
source.warnings.push(
@@ -161,7 +188,7 @@ export class AstAnalyser {
161188
}
162189

163190
// we walk each AST Nodes, this is a purely synchronous I/O
164-
walkEnter(body, function walk(node) {
191+
walkEnter(updatedBody, function walk(node) {
165192
// Skip the root of the AST.
166193
if (Array.isArray(node)) {
167194
return;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./JsSourceParser.js";
33
export * from "./AstAnalyser.js";
44
export * from "./EntryFilesAnalyser.js";
55
export * from "./SourceFile.js";
6+
export * from "./pipelines/index.js";
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 "./pipeline.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(body: ESTree.Program["body"]): ESTree.Program["body"] {
29+
const self = this;
30+
walkEnter(body, function walk(node): void {
31+
if (Array.isArray(node)) {
32+
return;
33+
}
34+
35+
const replaceNode = (newNode: ESTree.Node | void) => {
36+
if (newNode === undefined) {
37+
return;
38+
}
39+
this.replace(newNode);
40+
this.skip();
41+
};
42+
43+
match(node)
44+
.with({ type: "CallExpression" }, (node) => {
45+
replaceNode(self.#withCallExpression(node));
46+
})
47+
.otherwise(() => void 0);
48+
});
49+
50+
return body;
51+
}
52+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Import Internal Dependencies
2+
import { Deobfuscate } from "./deobfuscate.js";
3+
import type { Pipeline } from "./pipeline.js";
4+
5+
export const Pipelines = {
6+
deobfuscate: Deobfuscate
7+
} as const;
8+
9+
export type {
10+
Pipeline
11+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
test("should find a shady-url by using the built-in deobfuscate pipeline", () => {
69+
const analyser = new AstAnalyser({
70+
customParser: new JsSourceParser(),
71+
pipelines: [
72+
new Pipelines.deobfuscate()
73+
]
74+
});
75+
76+
const { warnings } = analyser.analyse(`
77+
const URL = ["http://", ["77", "244", "210", "1"].join("."), "/script"].join("");
78+
`);
79+
80+
assert.deepEqual(
81+
getWarningKind(warnings),
82+
["shady-link"].sort()
83+
);
84+
});
85+
});

0 commit comments

Comments
 (0)