diff --git a/README.md b/README.md index 9079223..75607b8 100644 --- a/README.md +++ b/README.md @@ -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
-runASTAnalysis(str: string, options?: RuntimeOptions): Report +runASTAnalysis(str: string, options?: RuntimeOptions & AstAnalyserOptions): Report ```ts interface RuntimeOptions { module?: boolean; - isMinified?: boolean; removeHTMLComments?: boolean; + isMinified?: boolean; +} +``` + +```ts +interface AstAnalyserOptions { + customParser?: SourceParser; + customProbes?: Probe[]; + skipDefaultProbes?: boolean; } ``` @@ -161,7 +236,7 @@ interface Report {
-runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions): Promise< ReportOnFile > +runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions & AstAnalyserOptions): Promise< ReportOnFile > ```ts interface RuntimeFileOptions { @@ -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 diff --git a/index.js b/index.js index f6cfbec..0702198 100644 --- a/index.js +++ b/index.js @@ -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); } @@ -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); } diff --git a/src/AstAnalyser.js b/src/AstAnalyser.js index d957699..15e8a72 100644 --- a/src/AstAnalyser.js +++ b/src/AstAnalyser.js @@ -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} [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)) { @@ -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, { diff --git a/src/SourceFile.js b/src/SourceFile.js index aa7513d..2c3e02b 100644 --- a/src/SourceFile.js +++ b/src/SourceFile.js @@ -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"); } diff --git a/test/AstAnalyser.spec.js b/test/AstAnalyser.spec.js index 3b792c3..0c611e2 100644 --- a/test/AstAnalyser.spec.js +++ b/test/AstAnalyser.spec.js @@ -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); @@ -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() => { @@ -206,7 +235,7 @@ describe("AstAnalyser", (t) => { const preparedSource = getAnalyser().prepareSource(` `, { @@ -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); + }); }); }); diff --git a/test/fixtures/searchRuntimeDependencies/customProbe.js b/test/fixtures/searchRuntimeDependencies/customProbe.js new file mode 100644 index 0000000..2cbced9 --- /dev/null +++ b/test/fixtures/searchRuntimeDependencies/customProbe.js @@ -0,0 +1,2 @@ +const danger = 'danger'; +const stream = eval('require')('stream'); diff --git a/test/runASTAnalysis.spec.js b/test/runASTAnalysis.spec.js index 318ff24..0cc6660 100644 --- a/test/runASTAnalysis.spec.js +++ b/test/runASTAnalysis.spec.js @@ -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"); @@ -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); +}); diff --git a/test/runASTAnalysisOnFile.spec.js b/test/runASTAnalysisOnFile.spec.js index 42a018d..bb8047a 100644 --- a/test/runASTAnalysisOnFile.spec.js +++ b/test/runASTAnalysisOnFile.spec.js @@ -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); @@ -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); +}); diff --git a/test/utils/index.js b/test/utils/index.js index b6a170e..722c598 100644 --- a/test/utils/index.js +++ b/test/utils/index.js @@ -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(); @@ -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"; diff --git a/types/api.d.ts b/types/api.d.ts index f81ea66..27d7ac4 100644 --- a/types/api.d.ts +++ b/types/api.d.ts @@ -42,13 +42,35 @@ interface RuntimeOptions { /** * @default false */ - isMinified?: boolean; + removeHTMLComments?: boolean; /** * @default false */ - removeHTMLComments?: boolean; - + isMinified?: boolean; +} + +interface RuntimeFileOptions extends Omit { + packageName?: string; +} + +interface AstAnalyserOptions { + /** + * @default JsSourceParser + */ customParser?: SourceParser; + /** + * @default [] + */ + customProbes?: Probe[]; + /** + * @default false + */ + skipDefaultProbes?: boolean; +} + +interface Probe { + validateNode: Function | Function[]; + main: Function; } interface Report { @@ -59,10 +81,6 @@ interface Report { isOneLineRequire: boolean; } -interface RuntimeFileOptions extends Omit { - packageName?: string; -} - type ReportOnFile = { ok: true, warnings: Warning[]; @@ -78,10 +96,10 @@ interface SourceParser { } declare class AstAnalyser { - constructor(parser?: SourceParser); - analyse: (str: string, options?: Omit) => Report; - analyzeFile(pathToFile: string, options?: Omit): Promise; + constructor(options?: AstAnalyserOptions); + analyse: (str: string, options?: RuntimeOptions) => Report; + analyzeFile(pathToFile: string, options?: RuntimeFileOptions): Promise; } -declare function runASTAnalysis(str: string, options?: RuntimeOptions): Report; -declare function runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions): Promise; +declare function runASTAnalysis(str: string, options?: RuntimeOptions & AstAnalyserOptions): Report; +declare function runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions & AstAnalyserOptions): Promise;