diff --git a/.changeset/quiet-files-melt.md b/.changeset/quiet-files-melt.md new file mode 100644 index 0000000..d3f270c --- /dev/null +++ b/.changeset/quiet-files-melt.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/js-x-ray": minor +--- + +Implement context for Probe and ProbeRunner diff --git a/workspaces/js-x-ray/docs/AstAnalyser.md b/workspaces/js-x-ray/docs/AstAnalyser.md index f3c010a..11fa8fe 100644 --- a/workspaces/js-x-ray/docs/AstAnalyser.md +++ b/workspaces/js-x-ray/docs/AstAnalyser.md @@ -143,11 +143,11 @@ Below a basic probe that detect a string assignation to `danger`: export const customProbes = [ { name: "customProbeUnsafeDanger", - validateNode: (node, sourceFile) => [ + validateNode: (node) => [ node.type === "VariableDeclaration" && node.declarations[0].init.value === "danger" ], - main: (node, options) => { - const { sourceFile, data: calleeName } = options; + main: (node, ctx) => { + const { sourceFile, data: calleeName } = ctx; if (node.declarations[0].init.value === "danger") { sourceFile.warnings.push({ kind: "unsafe-danger", diff --git a/workspaces/js-x-ray/src/ProbeRunner.ts b/workspaces/js-x-ray/src/ProbeRunner.ts index b37327a..884bfc2 100644 --- a/workspaces/js-x-ray/src/ProbeRunner.ts +++ b/workspaces/js-x-ray/src/ProbeRunner.ts @@ -24,24 +24,29 @@ import type { SourceFile } from "./SourceFile.js"; import type { OptionalWarningName } from "./warnings.js"; export type ProbeReturn = void | null | symbol; -export type ProbeInitializeCallback = (sourceFile: SourceFile) => void; -export type ProbeFinalizeCallback = (sourceFile: SourceFile) => void; -export type ProbeMainCallback = ( - node: any, - options: { sourceFile: SourceFile; data?: any; } -) => ProbeReturn; -export type ProbeTeardownCallback = (options: { sourceFile: SourceFile; }) => void; -export type ProbeValidationCallback = (node: ESTree.Node, sourceFile: SourceFile) => [boolean, any?]; - -export interface Probe { +export type ProbeContextDef = Record; +export type ProbeContext = { + sourceFile: SourceFile; + context?: T; +}; + +export type ProbeValidationCallback = ( + node: ESTree.Node, ctx: ProbeContext +) => [boolean, any?]; + +export interface Probe { name: string; - initialize?: ProbeInitializeCallback; - finalize?: ProbeFinalizeCallback; - validateNode: ProbeValidationCallback | ProbeValidationCallback[]; - main: ProbeMainCallback; - teardown?: ProbeTeardownCallback; + initialize?: (ctx: ProbeContext) => void | ProbeContext; + finalize?: (ctx: ProbeContext) => void; + validateNode: ProbeValidationCallback | ProbeValidationCallback[]; + main: ( + node: any, + ctx: ProbeContext & { data?: any; } + ) => ProbeReturn; + teardown?: (ctx: ProbeContext) => void; breakOnMatch?: boolean; breakGroup?: string; + context?: ProbeContext; } export const ProbeSignals = Object.freeze({ @@ -97,28 +102,42 @@ export class ProbeRunner { `Invalid probe ${probe.name}: initialize must be a function or undefined` ); if (probe.initialize) { - probe.initialize(sourceFile); + const context = probe.initialize(this.#getProbeContext(probe)); + if (context) { + probe.context = context; + } } } this.probes = probes; } + #getProbeContext( + probe: Probe + ): ProbeContext { + return { + sourceFile: this.sourceFile, + context: probe.context + }; + } + #runProbe( probe: Probe, node: ESTree.Node ): ProbeReturn { const validationFns = Array.isArray(probe.validateNode) ? probe.validateNode : [probe.validateNode]; + const ctx = this.#getProbeContext(probe); + for (const validateNode of validationFns) { const [isMatching, data = null] = validateNode( node, - this.sourceFile + ctx ); if (isMatching) { return probe.main(node, { - sourceFile: this.sourceFile, + ...ctx, data }); } @@ -158,9 +177,7 @@ export class ProbeRunner { } } finally { - if (probe.teardown) { - probe.teardown({ sourceFile: this.sourceFile }); - } + probe.teardown?.(this.#getProbeContext(probe)); } } @@ -169,9 +186,7 @@ export class ProbeRunner { finalize(): void { for (const probe of this.probes) { - if (probe.finalize) { - probe.finalize(this.sourceFile); - } + probe.finalize?.(this.#getProbeContext(probe)); } } } diff --git a/workspaces/js-x-ray/src/probes/isFetch.ts b/workspaces/js-x-ray/src/probes/isFetch.ts index 931880b..8069a52 100644 --- a/workspaces/js-x-ray/src/probes/isFetch.ts +++ b/workspaces/js-x-ray/src/probes/isFetch.ts @@ -3,12 +3,13 @@ import { getCallExpressionIdentifier } from "@nodesecure/estree-ast-utils"; import type { ESTree } from "meriyah"; // Import Internal Dependencies -import { SourceFile } from "../SourceFile.js"; +import type { ProbeContext } from "../ProbeRunner.js"; function validateNode( node: ESTree.Node, - { tracer }: SourceFile + ctx: ProbeContext ): [boolean, any?] { + const { tracer } = ctx.sourceFile; const id = getCallExpressionIdentifier(node); if (id === null) { @@ -20,13 +21,17 @@ function validateNode( return [data !== null && data.identifierOrMemberExpr === "fetch"]; } -function initialize(sourceFile: SourceFile) { +function initialize( + ctx: ProbeContext +) { + const { sourceFile } = ctx; + sourceFile.tracer.trace("fetch", { followConsecutiveAssignment: true }); } function main( _node: ESTree.Node, - { sourceFile }: { sourceFile: SourceFile; } + { sourceFile }: ProbeContext ) { sourceFile.flags.add("fetch"); } diff --git a/workspaces/js-x-ray/src/probes/isRequire/isRequire.ts b/workspaces/js-x-ray/src/probes/isRequire/isRequire.ts index 9c7719b..60ed01d 100644 --- a/workspaces/js-x-ray/src/probes/isRequire/isRequire.ts +++ b/workspaces/js-x-ray/src/probes/isRequire/isRequire.ts @@ -10,16 +10,16 @@ import { import type { ESTree } from "meriyah"; // Import Internal Dependencies -import { ProbeSignals } from "../../ProbeRunner.js"; -import { SourceFile } from "../../SourceFile.js"; +import { ProbeSignals, type ProbeContext } from "../../ProbeRunner.js"; import { isLiteral } from "../../types/estree.js"; import { RequireCallExpressionWalker } from "./RequireCallExpressionWalker.js"; import { generateWarning } from "../../warnings.js"; function validateNodeRequire( node: ESTree.Node, - { tracer }: SourceFile + ctx: ProbeContext ): [boolean, any?] { + const { tracer } = ctx.sourceFile; const id = getCallExpressionIdentifier(node, { resolveCallExpression: false }); @@ -63,14 +63,14 @@ function validateNodeEvalRequire( } function teardown( - { sourceFile }: { sourceFile: SourceFile; } + ctx: ProbeContext ) { - sourceFile.dependencyAutoWarning = false; + ctx.sourceFile.dependencyAutoWarning = false; } function main( node: ESTree.CallExpression, - options: { sourceFile: SourceFile; data?: string; } + options: ProbeContext & { data?: string; } ) { const { sourceFile, data: calleeName } = options; const { tracer } = sourceFile; diff --git a/workspaces/js-x-ray/src/probes/isSerializeEnv.ts b/workspaces/js-x-ray/src/probes/isSerializeEnv.ts index 340fc8f..c27fc50 100644 --- a/workspaces/js-x-ray/src/probes/isSerializeEnv.ts +++ b/workspaces/js-x-ray/src/probes/isSerializeEnv.ts @@ -6,9 +6,8 @@ import { import type { ESTree } from "meriyah"; // Import Internal Dependencies -import { SourceFile } from "../SourceFile.js"; import { generateWarning } from "../warnings.js"; -import { ProbeSignals } from "../ProbeRunner.js"; +import { ProbeSignals, type ProbeContext } from "../ProbeRunner.js"; /** * @description Detect serialization of process.env which could indicate environment variable exfiltration @@ -20,8 +19,10 @@ import { ProbeSignals } from "../ProbeRunner.js"; */ function validateNode( node: ESTree.Node, - { tracer }: SourceFile + ctx: ProbeContext ): [boolean, any?] { + const { tracer } = ctx.sourceFile; + const id = getCallExpressionIdentifier(node); if (id === null) { @@ -58,9 +59,9 @@ function validateNode( function main( node: ESTree.Node, - options: { sourceFile: SourceFile; } + ctx: ProbeContext ) { - const { sourceFile } = options; + const { sourceFile } = ctx; const warning = generateWarning("serialize-environment", { value: "JSON.stringify(process.env)", @@ -72,8 +73,10 @@ function main( } function initialize( - { tracer }: SourceFile + ctx: ProbeContext ) { + const { tracer } = ctx.sourceFile; + tracer .trace("process.env", { followConsecutiveAssignment: true diff --git a/workspaces/js-x-ray/src/probes/isSyncIO.ts b/workspaces/js-x-ray/src/probes/isSyncIO.ts index cbe1ad2..3f59360 100644 --- a/workspaces/js-x-ray/src/probes/isSyncIO.ts +++ b/workspaces/js-x-ray/src/probes/isSyncIO.ts @@ -3,7 +3,7 @@ import { getCallExpressionIdentifier } from "@nodesecure/estree-ast-utils"; import type { ESTree } from "meriyah"; // Import Internal Dependencies -import { SourceFile } from "../SourceFile.js"; +import type { ProbeContext } from "../ProbeRunner.js"; import { generateWarning } from "../warnings.js"; // CONSTANTS @@ -40,8 +40,9 @@ const kSyncIOIdentifierOrMemberExps = [ function validateNode( node: ESTree.Node, - { tracer }: SourceFile + ctx: ProbeContext ): [boolean, any?] { + const { tracer } = ctx.sourceFile; const id = getCallExpressionIdentifier( node, { @@ -64,12 +65,12 @@ function validateNode( } function initialize( - sourceFile: SourceFile + ctx: ProbeContext ) { kSyncIOIdentifierOrMemberExps.forEach((identifierOrMemberExp) => { const moduleName = identifierOrMemberExp.split(".")[0]; - return sourceFile.tracer.trace(identifierOrMemberExp, { + ctx.sourceFile.tracer.trace(identifierOrMemberExp, { followConsecutiveAssignment: true, moduleName }); @@ -78,13 +79,13 @@ function initialize( function main( node: ESTree.CallExpression, - { sourceFile }: { sourceFile: SourceFile; } + ctx: ProbeContext ) { const warning = generateWarning("synchronous-io", { value: node.callee.name, location: node.loc }); - sourceFile.warnings.push(warning); + ctx.sourceFile.warnings.push(warning); } export default { diff --git a/workspaces/js-x-ray/src/probes/isWeakCrypto.ts b/workspaces/js-x-ray/src/probes/isWeakCrypto.ts index e069918..54ef9e5 100644 --- a/workspaces/js-x-ray/src/probes/isWeakCrypto.ts +++ b/workspaces/js-x-ray/src/probes/isWeakCrypto.ts @@ -3,7 +3,7 @@ import { getCallExpressionIdentifier } from "@nodesecure/estree-ast-utils"; import type { ESTree } from "meriyah"; // Import Internal Dependencies -import { SourceFile } from "../SourceFile.js"; +import type { ProbeContext } from "../ProbeRunner.js"; import { generateWarning } from "../warnings.js"; import { isLiteral @@ -20,9 +20,9 @@ const kWeakAlgorithms = new Set([ function validateNode( node: ESTree.Node, - sourceFile: SourceFile + ctx: ProbeContext ): [boolean, any?] { - const { tracer } = sourceFile; + const { tracer } = ctx.sourceFile; const id = getCallExpressionIdentifier(node); if (id === null || !tracer.importedModules.has("crypto")) { @@ -37,9 +37,11 @@ function validateNode( } function initialize( - sourceFile: SourceFile + ctx: ProbeContext ) { - sourceFile.tracer.trace("crypto.createHash", { + const { tracer } = ctx.sourceFile; + + tracer.trace("crypto.createHash", { followConsecutiveAssignment: true, moduleName: "crypto" }); @@ -47,8 +49,9 @@ function initialize( function main( node: ESTree.CallExpression, - { sourceFile }: { sourceFile: SourceFile; } + ctx: ProbeContext ) { + const { sourceFile } = ctx; const arg = node.arguments.at(0); if (isLiteral(arg) && kWeakAlgorithms.has(arg.value)) { diff --git a/workspaces/js-x-ray/test/ProbeRunner.spec.ts b/workspaces/js-x-ray/test/ProbeRunner.spec.ts index 23061a0..8b5f778 100644 --- a/workspaces/js-x-ray/test/ProbeRunner.spec.ts +++ b/workspaces/js-x-ray/test/ProbeRunner.spec.ts @@ -113,12 +113,43 @@ describe("ProbeRunner", () => { assert.strictEqual(fakeProbe.main.mock.calls.length, 1); assert.deepEqual(fakeProbe.main.mock.calls.at(0)?.arguments, [ - astNode, { sourceFile, data: null } + astNode, { sourceFile, data: null, context: undefined } ]); assert.strictEqual(fakeProbe.teardown.mock.calls.length, 1); assert.deepEqual(fakeProbe.teardown.mock.calls.at(0)?.arguments, [ - { sourceFile } + { sourceFile, context: undefined } + ]); + }); + + it("should forward validateNode data to main", () => { + const data = { test: "data" }; + const fakeProbe = { + validateNode: mock.fn((_: ESTree.Node) => [true, data]), + main: mock.fn(() => ProbeSignals.Skip) + }; + + const sourceFile = new SourceFile(); + + const pr = new ProbeRunner( + sourceFile, + // @ts-expect-error + [fakeProbe] + ); + + const astNode: ESTree.Literal = { + type: "Literal", + value: "test" + }; + pr.walk(astNode); + pr.finalize(); + + const expectedContext = { sourceFile, context: undefined }; + assert.deepEqual(fakeProbe.validateNode.mock.calls.at(0)?.arguments, [ + astNode, expectedContext + ]); + assert.deepEqual(fakeProbe.main.mock.calls.at(0)?.arguments, [ + astNode, { ...expectedContext, data } ]); }); @@ -183,9 +214,91 @@ describe("ProbeRunner", () => { probes.forEach((probe) => { assert.strictEqual(probe.finalize.mock.calls.length, 1); assert.deepEqual(probe.finalize.mock.calls.at(0)?.arguments, [ - sourceFile + { sourceFile, context: undefined } ]); }); }); }); + + describe("context", () => { + it("should define context with initialize and dispatch it to all methods", () => { + const fakeCtx = {}; + + const fakeProbe = { + initialize: mock.fn(() => fakeCtx), + validateNode: mock.fn((_: ESTree.Node) => [true]), + main: mock.fn(() => ProbeSignals.Skip), + finalize: mock.fn() + }; + + const sourceFile = new SourceFile(); + + const pr = new ProbeRunner( + sourceFile, + // @ts-expect-error + [fakeProbe] + ); + + const astNode: ESTree.Literal = { + type: "Literal", + value: "test" + }; + pr.walk(astNode); + pr.finalize(); + + const expectedContext = { sourceFile, context: fakeCtx }; + assert.deepEqual(fakeProbe.validateNode.mock.calls.at(0)?.arguments, [ + astNode, expectedContext + ]); + assert.deepEqual(fakeProbe.main.mock.calls.at(0)?.arguments, [ + astNode, { ...expectedContext, data: null } + ]); + assert.deepEqual(fakeProbe.initialize.mock.calls.at(0)?.arguments, [ + { sourceFile, context: undefined } + ]); + assert.deepEqual(fakeProbe.finalize.mock.calls.at(0)?.arguments, [ + expectedContext + ]); + }); + + it("should define context within the probe and dispatch it to all methods", () => { + const fakeCtx = {}; + const fakeProbe = { + initialize: mock.fn(), + validateNode: mock.fn((_: ESTree.Node) => [true]), + main: mock.fn(() => ProbeSignals.Skip), + finalize: mock.fn(), + context: fakeCtx + }; + + const sourceFile = new SourceFile(); + + const pr = new ProbeRunner( + sourceFile, + // @ts-expect-error + [fakeProbe] + ); + + const astNode: ESTree.Literal = { + type: "Literal", + value: "test" + }; + pr.walk(astNode); + pr.finalize(); + + const expectedContext = { sourceFile, context: fakeCtx }; + assert.deepEqual(fakeProbe.validateNode.mock.calls.at(0)?.arguments, [ + astNode, expectedContext + ]); + assert.deepEqual(fakeProbe.main.mock.calls.at(0)?.arguments, [ + astNode, { ...expectedContext, data: null } + ]); + assert.deepEqual(fakeProbe.finalize.mock.calls.at(0)?.arguments, [ + expectedContext + ]); + assert.deepEqual(fakeProbe.initialize.mock.calls.at(0)?.arguments, [ + expectedContext + ]); + }); + }); });