Skip to content

Commit c157bae

Browse files
authored
feat(customProbes): inject custom probes as param for AstAnalyser (#250)
* kickstart custom probe as param * append/replace custom probe in ASTAnalyzer class * start refacto options param * refacto ASTAnalyzer options to be a unique object * refacto probesOptions ASTAnalyzer * fix last typos * refacto tests * refacto tests && update docs * update readme customProbes
1 parent 4394c8f commit c157bae

File tree

10 files changed

+280
-26
lines changed

10 files changed

+280
-26
lines changed

README.md

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,90 @@ This section describe all the possible warnings returned by JSXRay. Click on the
134134
| [weak-crypto](./docs/weak-crypto.md) | ✔️ | The code probably contains a weak crypto algorithm (md5, sha1...) |
135135
| [shady-link](./docs/shady-link.md) | ✔️ | The code contains shady/unsafe link |
136136

137+
## Custom Probes
138+
139+
You can also create custom probes to detect specific pattern in the code you are analyzing.
140+
141+
A probe is a pair of two functions (`validateNode` and `main`) that will be called on each node of the AST. It will return a warning if the pattern is detected.
142+
Below a basic probe that detect a string assignation to `danger`:
143+
144+
```ts
145+
export const customProbes = [
146+
{
147+
name: "customProbeUnsafeDanger",
148+
validateNode: (node, sourceFile) => [
149+
node.type === "VariableDeclaration" && node.declarations[0].init.value === "danger"
150+
],
151+
main: (node, options) => {
152+
const { sourceFile, data: calleeName } = options;
153+
if (node.declarations[0].init.value === "danger") {
154+
sourceFile.addWarning("unsafe-danger", calleeName, node.loc);
155+
156+
return ProbeSignals.Skip;
157+
}
158+
159+
return null;
160+
}
161+
}
162+
];
163+
```
164+
165+
You can pass an array of probes to the `runASTAnalysis/runASTAnalysisOnFile` functions as `options`, or directly to the `AstAnalyser` constructor.
166+
167+
| Name | Type | Description | Default Value |
168+
|------------------|----------------------------------|-----------------------------------------------------------------------|-----------------|
169+
| `customParser` | `SourceParser \| undefined` | An optional custom parser to be used for parsing the source code. | `JsSourceParser` |
170+
| `customProbes` | `Probe[] \| undefined` | An array of custom probes to be used during AST analysis. | `[]` |
171+
| `skipDefaultProbes` | `boolean \| undefined` | If `true`, default probes will be skipped and only custom probes will be used. | `false` |
172+
173+
174+
Here using the example probe upper:
175+
176+
```ts
177+
import { runASTAnalysis } from "@nodesecure/js-x-ray";
178+
179+
// add your customProbes here (see example above)
180+
181+
const result = runASTAnalysis("const danger = 'danger';", { customProbes, skipDefaultProbes: true });
182+
183+
console.log(result);
184+
```
185+
186+
Result:
187+
188+
```sh
189+
✗ node example.js
190+
{
191+
idsLengthAvg: 0,
192+
stringScore: 0,
193+
warnings: [ { kind: 'unsafe-danger', location: [Array], source: 'JS-X-Ray' } ],
194+
dependencies: Map(0) {},
195+
isOneLineRequire: false
196+
}
197+
```
198+
199+
Congrats, you have created your first custom probe! 🎉
200+
201+
> [!TIP]
202+
> Check the types in [index.d.ts](index.d.ts) and [types/api.d.ts](types/api.d.ts) for more details about the `options`
203+
137204
## API
138205
<details>
139-
<summary>runASTAnalysis(str: string, options?: RuntimeOptions): Report</summary>
206+
<summary>runASTAnalysis(str: string, options?: RuntimeOptions & AstAnalyserOptions): Report</summary>
140207
141208
```ts
142209
interface RuntimeOptions {
143210
module?: boolean;
144-
isMinified?: boolean;
145211
removeHTMLComments?: boolean;
212+
isMinified?: boolean;
213+
}
214+
```
215+
216+
```ts
217+
interface AstAnalyserOptions {
218+
customParser?: SourceParser;
219+
customProbes?: Probe[];
220+
skipDefaultProbes?: boolean;
146221
}
147222
```
148223
@@ -161,7 +236,7 @@ interface Report {
161236
</details>
162237
163238
<details>
164-
<summary>runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions): Promise< ReportOnFile ></summary>
239+
<summary>runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions & AstAnalyserOptions): Promise< ReportOnFile ></summary>
165240
166241
```ts
167242
interface RuntimeFileOptions {
@@ -171,6 +246,14 @@ interface RuntimeFileOptions {
171246
}
172247
```
173248
249+
```ts
250+
interface AstAnalyserOptions {
251+
customParser?: SourceParser;
252+
customProbes?: Probe[];
253+
skipDefaultProbes?: boolean;
254+
}
255+
```
256+
174257
Run the SAST scanner on a given JavaScript file.
175258
176259
```ts

index.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ function runASTAnalysis(
99
) {
1010
const {
1111
customParser = new JsSourceParser(),
12+
customProbes = [],
13+
skipDefaultProbes = false,
1214
...opts
1315
} = options;
1416

15-
const analyser = new AstAnalyser(customParser);
17+
const analyser = new AstAnalyser({
18+
customParser,
19+
customProbes,
20+
skipDefaultProbes
21+
});
1622

1723
return analyser.analyse(str, opts);
1824
}
@@ -23,10 +29,16 @@ async function runASTAnalysisOnFile(
2329
) {
2430
const {
2531
customParser = new JsSourceParser(),
32+
customProbes = [],
33+
skipDefaultProbes = false,
2634
...opts
2735
} = options;
2836

29-
const analyser = new AstAnalyser(customParser);
37+
const analyser = new AstAnalyser({
38+
customParser,
39+
customProbes,
40+
skipDefaultProbes
41+
});
3042

3143
return analyser.analyseFile(pathToFile, opts);
3244
}

src/AstAnalyser.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@ import { JsSourceParser } from "./JsSourceParser.js";
1414
export class AstAnalyser {
1515
/**
1616
* @constructor
17-
* @param { SourceParser } [parser]
17+
* @param {object} [options={}]
18+
* @param {SourceParser} [options.customParser]
19+
* @param {Array<object>} [options.customProbes]
20+
* @param {boolean} [options.skipDefaultProbes=false]
1821
*/
19-
constructor(parser = new JsSourceParser()) {
20-
this.parser = parser;
22+
constructor(options = {}) {
23+
this.parser = options.customParser ?? new JsSourceParser();
24+
this.probesOptions = {
25+
customProbes: options.customProbes ?? [],
26+
skipDefaultProbes: options.skipDefaultProbes ?? false
27+
};
2128
}
2229

2330
analyse(str, options = Object.create(null)) {
@@ -31,7 +38,7 @@ export class AstAnalyser {
3138
isEcmaScriptModule: Boolean(module)
3239
});
3340

34-
const source = new SourceFile(str);
41+
const source = new SourceFile(str, this.probesOptions);
3542

3643
// we walk each AST Nodes, this is a purely synchronous I/O
3744
walk(body, {

src/SourceFile.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@ export class SourceFile {
2020
encodedLiterals = new Map();
2121
warnings = [];
2222

23-
constructor(sourceCodeString) {
23+
constructor(sourceCodeString, probesOptions = {}) {
2424
this.tracer = new VariableTracer()
2525
.enableDefaultTracing()
2626
.trace("crypto.createHash", {
2727
followConsecutiveAssignment: true, moduleName: "crypto"
2828
});
2929

30-
this.probesRunner = new ProbeRunner(this);
30+
let probes = ProbeRunner.Defaults;
31+
if (Array.isArray(probesOptions.customProbes) && probesOptions.customProbes.length > 0) {
32+
probes = probesOptions.skipDefaultProbes === true ? probesOptions.customProbes : [...probes, ...probesOptions.customProbes];
33+
}
34+
this.probesRunner = new ProbeRunner(this, probes);
35+
3136
if (trojan.verify(sourceCodeString)) {
3237
this.addWarning("obfuscated-code", "trojan-source");
3338
}

test/AstAnalyser.spec.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import { readFileSync } from "node:fs";
66
// Import Internal Dependencies
77
import { AstAnalyser } from "../src/AstAnalyser.js";
88
import { JsSourceParser } from "../src/JsSourceParser.js";
9-
import { getWarningKind } from "./utils/index.js";
9+
import {
10+
customProbes,
11+
getWarningKind,
12+
kIncriminedCodeSampleCustomProbe,
13+
kWarningUnsafeDanger,
14+
kWarningUnsafeImport,
15+
kWarningUnsafeStmt
16+
} from "./utils/index.js";
1017

1118
// CONSTANTS
1219
const FIXTURE_URL = new URL("fixtures/searchRuntimeDependencies/", import.meta.url);
@@ -145,6 +152,28 @@ describe("AstAnalyser", (t) => {
145152
["http", "fs", "xd"].sort()
146153
);
147154
});
155+
156+
it("should append to list of probes (default)", () => {
157+
const analyser = new AstAnalyser({ customParser: new JsSourceParser(), customProbes });
158+
const result = analyser.analyse(kIncriminedCodeSampleCustomProbe);
159+
160+
assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
161+
assert.equal(result.warnings[1].kind, kWarningUnsafeImport);
162+
assert.equal(result.warnings[2].kind, kWarningUnsafeStmt);
163+
assert.equal(result.warnings.length, 3);
164+
});
165+
166+
it("should replace list of probes", () => {
167+
const analyser = new AstAnalyser({
168+
parser: new JsSourceParser(),
169+
customProbes,
170+
skipDefaultProbes: true
171+
});
172+
const result = analyser.analyse(kIncriminedCodeSampleCustomProbe);
173+
174+
assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
175+
assert.equal(result.warnings.length, 1);
176+
});
148177
});
149178

150179
it("remove the packageName from the dependencies list", async() => {
@@ -206,7 +235,7 @@ describe("AstAnalyser", (t) => {
206235
const preparedSource = getAnalyser().prepareSource(`
207236
<!--
208237
// == fake comment == //
209-
238+
210239
const yo = 5;
211240
//-->
212241
`, {
@@ -236,6 +265,13 @@ describe("AstAnalyser", (t) => {
236265
assert.deepEqual([...result.dependencies.keys()], []);
237266
});
238267
});
268+
269+
it("should instantiate with correct default options", () => {
270+
const analyser = new AstAnalyser();
271+
assert.ok(analyser.parser instanceof JsSourceParser);
272+
assert.deepStrictEqual(analyser.probesOptions.customProbes, []);
273+
assert.strictEqual(analyser.probesOptions.skipDefaultProbes, false);
274+
});
239275
});
240276
});
241277

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
const danger = 'danger';
2+
const stream = eval('require')('stream');

test/runASTAnalysis.spec.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { runASTAnalysis } from "../index.js";
77
import { AstAnalyser } from "../src/AstAnalyser.js";
88
import { JsSourceParser } from "../src/JsSourceParser.js";
99
import { FakeSourceParser } from "./fixtures/FakeSourceParser.js";
10+
import {
11+
customProbes,
12+
kIncriminedCodeSampleCustomProbe,
13+
kWarningUnsafeDanger,
14+
kWarningUnsafeImport,
15+
kWarningUnsafeStmt
16+
} from "./utils/index.js";
1017

1118
it("should call AstAnalyser.analyse with the expected arguments", (t) => {
1219
t.mock.method(AstAnalyser.prototype, "analyse");
@@ -37,3 +44,33 @@ it("should instantiate AstAnalyser with the expected parser", (t) => {
3744
assert.strictEqual(JsSourceParser.prototype.parse.mock.calls.length, 1);
3845
assert.strictEqual(FakeSourceParser.prototype.parse.mock.calls.length, 1);
3946
});
47+
48+
it("should append list of probes using runASTAnalysis", () => {
49+
const result = runASTAnalysis(
50+
kIncriminedCodeSampleCustomProbe,
51+
{
52+
parser: new JsSourceParser(),
53+
customProbes,
54+
skipDefaultProbes: false
55+
}
56+
);
57+
58+
assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
59+
assert.equal(result.warnings[1].kind, kWarningUnsafeImport);
60+
assert.equal(result.warnings[2].kind, kWarningUnsafeStmt);
61+
assert.equal(result.warnings.length, 3);
62+
});
63+
64+
it("should replace list of probes using runASTAnalysis", () => {
65+
const result = runASTAnalysis(
66+
kIncriminedCodeSampleCustomProbe,
67+
{
68+
parser: new JsSourceParser(),
69+
customProbes,
70+
skipDefaultProbes: true
71+
}
72+
);
73+
74+
assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
75+
assert.equal(result.warnings.length, 1);
76+
});

test/runASTAnalysisOnFile.spec.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { runASTAnalysisOnFile } from "../index.js";
77
import { AstAnalyser } from "../src/AstAnalyser.js";
88
import { FakeSourceParser } from "./fixtures/FakeSourceParser.js";
99
import { JsSourceParser } from "../src/JsSourceParser.js";
10+
import { customProbes, kWarningUnsafeDanger, kWarningUnsafeImport, kWarningUnsafeStmt } from "./utils/index.js";
1011

1112
// CONSTANTS
1213
const FIXTURE_URL = new URL("fixtures/searchRuntimeDependencies/", import.meta.url);
@@ -50,3 +51,33 @@ it("should instantiate AstAnalyser with the expected parser", async(t) => {
5051
assert.strictEqual(JsSourceParser.prototype.parse.mock.calls.length, 1);
5152
assert.strictEqual(FakeSourceParser.prototype.parse.mock.calls.length, 1);
5253
});
54+
55+
it("should append list of probes using runASTAnalysisOnFile", async() => {
56+
const result = await runASTAnalysisOnFile(
57+
new URL("customProbe.js", FIXTURE_URL),
58+
{
59+
parser: new JsSourceParser(),
60+
customProbes,
61+
skipDefaultProbes: false
62+
}
63+
);
64+
65+
assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
66+
assert.equal(result.warnings[1].kind, kWarningUnsafeImport);
67+
assert.equal(result.warnings[2].kind, kWarningUnsafeStmt);
68+
assert.equal(result.warnings.length, 3);
69+
});
70+
71+
it("should replace list of probes using runASTAnalysisOnFile", async() => {
72+
const result = await runASTAnalysisOnFile(
73+
new URL("customProbe.js", FIXTURE_URL),
74+
{
75+
parser: new JsSourceParser(),
76+
customProbes,
77+
skipDefaultProbes: true
78+
}
79+
);
80+
81+
assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
82+
assert.equal(result.warnings.length, 1);
83+
});

0 commit comments

Comments
 (0)