Skip to content

Commit

Permalink
feat(customProbes): inject custom probes as param for AstAnalyser (#250)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tchapacan authored Mar 28, 2024
1 parent 4394c8f commit c157bae
Show file tree
Hide file tree
Showing 10 changed files with 280 additions and 26 deletions.
89 changes: 86 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,90 @@ This section describe all the possible warnings returned by JSXRay. Click on the
| [weak-crypto](./docs/weak-crypto.md) | ✔️ | The code probably contains a weak crypto algorithm (md5, sha1...) |
| [shady-link](./docs/shady-link.md) | ✔️ | The code contains shady/unsafe link |

## Custom Probes

You can also create custom probes to detect specific pattern in the code you are analyzing.

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.
Below a basic probe that detect a string assignation to `danger`:

```ts
export const customProbes = [
{
name: "customProbeUnsafeDanger",
validateNode: (node, sourceFile) => [
node.type === "VariableDeclaration" && node.declarations[0].init.value === "danger"
],
main: (node, options) => {
const { sourceFile, data: calleeName } = options;
if (node.declarations[0].init.value === "danger") {
sourceFile.addWarning("unsafe-danger", calleeName, node.loc);

return ProbeSignals.Skip;
}

return null;
}
}
];
```

You can pass an array of probes to the `runASTAnalysis/runASTAnalysisOnFile` functions as `options`, or directly to the `AstAnalyser` constructor.

| Name | Type | Description | Default Value |
|------------------|----------------------------------|-----------------------------------------------------------------------|-----------------|
| `customParser` | `SourceParser \| undefined` | An optional custom parser to be used for parsing the source code. | `JsSourceParser` |
| `customProbes` | `Probe[] \| undefined` | An array of custom probes to be used during AST analysis. | `[]` |
| `skipDefaultProbes` | `boolean \| undefined` | If `true`, default probes will be skipped and only custom probes will be used. | `false` |


Here using the example probe upper:

```ts
import { runASTAnalysis } from "@nodesecure/js-x-ray";

// add your customProbes here (see example above)

const result = runASTAnalysis("const danger = 'danger';", { customProbes, skipDefaultProbes: true });

console.log(result);
```

Result:

```sh
✗ node example.js
{
idsLengthAvg: 0,
stringScore: 0,
warnings: [ { kind: 'unsafe-danger', location: [Array], source: 'JS-X-Ray' } ],
dependencies: Map(0) {},
isOneLineRequire: false
}
```
Congrats, you have created your first custom probe! 🎉
> [!TIP]
> 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`
## API
<details>
<summary>runASTAnalysis(str: string, options?: RuntimeOptions): Report</summary>
<summary>runASTAnalysis(str: string, options?: RuntimeOptions & AstAnalyserOptions): Report</summary>
```ts
interface RuntimeOptions {
module?: boolean;
isMinified?: boolean;
removeHTMLComments?: boolean;
isMinified?: boolean;
}
```
```ts
interface AstAnalyserOptions {
customParser?: SourceParser;
customProbes?: Probe[];
skipDefaultProbes?: boolean;
}
```
Expand All @@ -161,7 +236,7 @@ interface Report {
</details>
<details>
<summary>runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions): Promise< ReportOnFile ></summary>
<summary>runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions & AstAnalyserOptions): Promise< ReportOnFile ></summary>
```ts
interface RuntimeFileOptions {
Expand All @@ -171,6 +246,14 @@ interface RuntimeFileOptions {
}
```
```ts
interface AstAnalyserOptions {
customParser?: SourceParser;
customProbes?: Probe[];
skipDefaultProbes?: boolean;
}
```
Run the SAST scanner on a given JavaScript file.
```ts
Expand Down
16 changes: 14 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ function runASTAnalysis(
) {
const {
customParser = new JsSourceParser(),
customProbes = [],
skipDefaultProbes = false,
...opts
} = options;

const analyser = new AstAnalyser(customParser);
const analyser = new AstAnalyser({
customParser,
customProbes,
skipDefaultProbes
});

return analyser.analyse(str, opts);
}
Expand All @@ -23,10 +29,16 @@ async function runASTAnalysisOnFile(
) {
const {
customParser = new JsSourceParser(),
customProbes = [],
skipDefaultProbes = false,
...opts
} = options;

const analyser = new AstAnalyser(customParser);
const analyser = new AstAnalyser({
customParser,
customProbes,
skipDefaultProbes
});

return analyser.analyseFile(pathToFile, opts);
}
Expand Down
15 changes: 11 additions & 4 deletions src/AstAnalyser.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,17 @@ import { JsSourceParser } from "./JsSourceParser.js";
export class AstAnalyser {
/**
* @constructor
* @param { SourceParser } [parser]
* @param {object} [options={}]
* @param {SourceParser} [options.customParser]
* @param {Array<object>} [options.customProbes]
* @param {boolean} [options.skipDefaultProbes=false]
*/
constructor(parser = new JsSourceParser()) {
this.parser = parser;
constructor(options = {}) {
this.parser = options.customParser ?? new JsSourceParser();
this.probesOptions = {
customProbes: options.customProbes ?? [],
skipDefaultProbes: options.skipDefaultProbes ?? false
};
}

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

const source = new SourceFile(str);
const source = new SourceFile(str, this.probesOptions);

// we walk each AST Nodes, this is a purely synchronous I/O
walk(body, {
Expand Down
9 changes: 7 additions & 2 deletions src/SourceFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ export class SourceFile {
encodedLiterals = new Map();
warnings = [];

constructor(sourceCodeString) {
constructor(sourceCodeString, probesOptions = {}) {
this.tracer = new VariableTracer()
.enableDefaultTracing()
.trace("crypto.createHash", {
followConsecutiveAssignment: true, moduleName: "crypto"
});

this.probesRunner = new ProbeRunner(this);
let probes = ProbeRunner.Defaults;
if (Array.isArray(probesOptions.customProbes) && probesOptions.customProbes.length > 0) {
probes = probesOptions.skipDefaultProbes === true ? probesOptions.customProbes : [...probes, ...probesOptions.customProbes];
}
this.probesRunner = new ProbeRunner(this, probes);

if (trojan.verify(sourceCodeString)) {
this.addWarning("obfuscated-code", "trojan-source");
}
Expand Down
40 changes: 38 additions & 2 deletions test/AstAnalyser.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import { readFileSync } from "node:fs";
// Import Internal Dependencies
import { AstAnalyser } from "../src/AstAnalyser.js";
import { JsSourceParser } from "../src/JsSourceParser.js";
import { getWarningKind } from "./utils/index.js";
import {
customProbes,
getWarningKind,
kIncriminedCodeSampleCustomProbe,
kWarningUnsafeDanger,
kWarningUnsafeImport,
kWarningUnsafeStmt
} from "./utils/index.js";

// CONSTANTS
const FIXTURE_URL = new URL("fixtures/searchRuntimeDependencies/", import.meta.url);
Expand Down Expand Up @@ -145,6 +152,28 @@ describe("AstAnalyser", (t) => {
["http", "fs", "xd"].sort()
);
});

it("should append to list of probes (default)", () => {
const analyser = new AstAnalyser({ customParser: new JsSourceParser(), customProbes });
const result = analyser.analyse(kIncriminedCodeSampleCustomProbe);

assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
assert.equal(result.warnings[1].kind, kWarningUnsafeImport);
assert.equal(result.warnings[2].kind, kWarningUnsafeStmt);
assert.equal(result.warnings.length, 3);
});

it("should replace list of probes", () => {
const analyser = new AstAnalyser({
parser: new JsSourceParser(),
customProbes,
skipDefaultProbes: true
});
const result = analyser.analyse(kIncriminedCodeSampleCustomProbe);

assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
assert.equal(result.warnings.length, 1);
});
});

it("remove the packageName from the dependencies list", async() => {
Expand Down Expand Up @@ -206,7 +235,7 @@ describe("AstAnalyser", (t) => {
const preparedSource = getAnalyser().prepareSource(`
<!--
// == fake comment == //
const yo = 5;
//-->
`, {
Expand Down Expand Up @@ -236,6 +265,13 @@ describe("AstAnalyser", (t) => {
assert.deepEqual([...result.dependencies.keys()], []);
});
});

it("should instantiate with correct default options", () => {
const analyser = new AstAnalyser();
assert.ok(analyser.parser instanceof JsSourceParser);
assert.deepStrictEqual(analyser.probesOptions.customProbes, []);
assert.strictEqual(analyser.probesOptions.skipDefaultProbes, false);
});
});
});

Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/searchRuntimeDependencies/customProbe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
const danger = 'danger';
const stream = eval('require')('stream');
37 changes: 37 additions & 0 deletions test/runASTAnalysis.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { runASTAnalysis } from "../index.js";
import { AstAnalyser } from "../src/AstAnalyser.js";
import { JsSourceParser } from "../src/JsSourceParser.js";
import { FakeSourceParser } from "./fixtures/FakeSourceParser.js";
import {
customProbes,
kIncriminedCodeSampleCustomProbe,
kWarningUnsafeDanger,
kWarningUnsafeImport,
kWarningUnsafeStmt
} from "./utils/index.js";

it("should call AstAnalyser.analyse with the expected arguments", (t) => {
t.mock.method(AstAnalyser.prototype, "analyse");
Expand Down Expand Up @@ -37,3 +44,33 @@ it("should instantiate AstAnalyser with the expected parser", (t) => {
assert.strictEqual(JsSourceParser.prototype.parse.mock.calls.length, 1);
assert.strictEqual(FakeSourceParser.prototype.parse.mock.calls.length, 1);
});

it("should append list of probes using runASTAnalysis", () => {
const result = runASTAnalysis(
kIncriminedCodeSampleCustomProbe,
{
parser: new JsSourceParser(),
customProbes,
skipDefaultProbes: false
}
);

assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
assert.equal(result.warnings[1].kind, kWarningUnsafeImport);
assert.equal(result.warnings[2].kind, kWarningUnsafeStmt);
assert.equal(result.warnings.length, 3);
});

it("should replace list of probes using runASTAnalysis", () => {
const result = runASTAnalysis(
kIncriminedCodeSampleCustomProbe,
{
parser: new JsSourceParser(),
customProbes,
skipDefaultProbes: true
}
);

assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
assert.equal(result.warnings.length, 1);
});
31 changes: 31 additions & 0 deletions test/runASTAnalysisOnFile.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { runASTAnalysisOnFile } from "../index.js";
import { AstAnalyser } from "../src/AstAnalyser.js";
import { FakeSourceParser } from "./fixtures/FakeSourceParser.js";
import { JsSourceParser } from "../src/JsSourceParser.js";
import { customProbes, kWarningUnsafeDanger, kWarningUnsafeImport, kWarningUnsafeStmt } from "./utils/index.js";

// CONSTANTS
const FIXTURE_URL = new URL("fixtures/searchRuntimeDependencies/", import.meta.url);
Expand Down Expand Up @@ -50,3 +51,33 @@ it("should instantiate AstAnalyser with the expected parser", async(t) => {
assert.strictEqual(JsSourceParser.prototype.parse.mock.calls.length, 1);
assert.strictEqual(FakeSourceParser.prototype.parse.mock.calls.length, 1);
});

it("should append list of probes using runASTAnalysisOnFile", async() => {
const result = await runASTAnalysisOnFile(
new URL("customProbe.js", FIXTURE_URL),
{
parser: new JsSourceParser(),
customProbes,
skipDefaultProbes: false
}
);

assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
assert.equal(result.warnings[1].kind, kWarningUnsafeImport);
assert.equal(result.warnings[2].kind, kWarningUnsafeStmt);
assert.equal(result.warnings.length, 3);
});

it("should replace list of probes using runASTAnalysisOnFile", async() => {
const result = await runASTAnalysisOnFile(
new URL("customProbe.js", FIXTURE_URL),
{
parser: new JsSourceParser(),
customProbes,
skipDefaultProbes: true
}
);

assert.equal(result.warnings[0].kind, kWarningUnsafeDanger);
assert.equal(result.warnings.length, 1);
});
Loading

0 comments on commit c157bae

Please sign in to comment.