Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(customProbes): inject custom probes as param for AstAnalyser #250

Merged
merged 9 commits into from
Mar 28, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 84 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,88 @@ 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"]
,
tchapacan marked this conversation as resolved.
Show resolved Hide resolved
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! 🎉

> 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`
>
tchapacan marked this conversation as resolved.
Show resolved Hide resolved
## API
<details>
<summary>runASTAnalysis(str: string, options?: RuntimeOptions): Report</summary>
<summary>declare function runASTAnalysis(str: string, options?: RuntimeOptions & AstAnalyserOptions): Report</summary>
tchapacan marked this conversation as resolved.
Show resolved Hide resolved

```ts
interface RuntimeOptions {
module?: boolean;
isMinified?: boolean;
removeHTMLComments?: boolean;
isMinified?: boolean;
}
```

```ts
interface AstAnalyserOptions {
customParser?: SourceParser;
customProbes?: Probe[];
skipDefaultProbes?: boolean;
}
```

Expand All @@ -161,7 +234,7 @@ interface Report {
</details>

<details>
<summary>runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions): Promise< ReportOnFile ></summary>
<summary>declare function runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions & AstAnalyserOptions): Promise< ReportOnFile ></summary>

```ts
interface RuntimeFileOptions {
Expand All @@ -171,6 +244,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
};
tchapacan marked this conversation as resolved.
Show resolved Hide resolved
}

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);
});
25 changes: 24 additions & 1 deletion test/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { walk } from "estree-walker";

// Import Internal Dependencies
import { SourceFile } from "../../src/SourceFile.js";
import { ProbeRunner } from "../../src/ProbeRunner.js";
import { ProbeRunner, ProbeSignals } from "../../src/ProbeRunner.js";

export function getWarningKind(warnings) {
return warnings.slice().map((warn) => warn.kind).sort();
Expand Down Expand Up @@ -61,3 +61,26 @@ export function getSastAnalysis(
}
};
}

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;
}
}
];

export const kIncriminedCodeSampleCustomProbe = "const danger = 'danger'; const stream = eval('require')('stream');";
export const kWarningUnsafeDanger = "unsafe-danger";
export const kWarningUnsafeImport = "unsafe-import";
export const kWarningUnsafeStmt = "unsafe-stmt";
Loading
Loading