Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/clean-security-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nodesecure/js-x-ray": minor
---

feat: add prototype pollution detection probe
4 changes: 3 additions & 1 deletion workspaces/js-x-ray/src/ProbeRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import isUnsafeCallee from "./probes/isUnsafeCallee.ts";
import isUnsafeCommand from "./probes/isUnsafeCommand.ts";
import isWeakCrypto from "./probes/isWeakCrypto.ts";
import isMonkeyPatch from "./probes/isMonkeyPatch.ts";
import isPrototypePollution from "./probes/isPrototypePollution.ts";

import type { TracedIdentifierReport } from "./VariableTracer.ts";
import type { SourceFile } from "./SourceFile.ts";
Expand Down Expand Up @@ -102,7 +103,8 @@ export class ProbeRunner {
isSerializeEnv,
dataExfiltration,
sqlInjection,
isMonkeyPatch
isMonkeyPatch,
isPrototypePollution
];

static Optionals: Record<OptionalWarningName, Probe> = {
Expand Down
52 changes: 52 additions & 0 deletions workspaces/js-x-ray/src/probes/isPrototypePollution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Import Third-party Dependencies
import type { ESTree } from "meriyah";
import { getMemberExpressionIdentifier } from "@nodesecure/estree-ast-utils";

// Import Internal Dependencies
import { SourceFile } from "../SourceFile.ts";
import { generateWarning } from "../warnings.ts";

function validateNode(
node: ESTree.Node
): [boolean, string?] {
if (node.type === "Literal" && node.value === "__proto__") {
return [true, "literal"];
}

if (node.type === "MemberExpression") {
const parts = [...getMemberExpressionIdentifier(node)];

if (parts.at(-1) === "__proto__") {
return [true, parts.join(".")];
}
}

return [false];
}

function main(
node: ESTree.Literal | ESTree.MemberExpression,
options: {
sourceFile: SourceFile;
data?: string;
signals: { Skip: symbol; };
}
) {
const { sourceFile, data, signals } = options;

sourceFile.warnings.push(
generateWarning("prototype-pollution", {
value: data === "literal" ? "__proto__" : data!,
location: node.loc ?? null
})
);

return data === "literal" ? undefined : signals.Skip;
}

export default {
name: "isPrototypePollution",
validateNode,
main,
breakOnMatch: false
};
6 changes: 6 additions & 0 deletions workspaces/js-x-ray/src/warnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type WarningName =
| "data-exfiltration"
| "sql-injection"
| "monkey-patch"
| "prototype-pollution"
| OptionalWarningName;

export interface Warning<T = WarningName> {
Expand Down Expand Up @@ -133,6 +134,11 @@ export const warnings = Object.freeze({
i18n: "sast_warnings.monkey_patch",
severity: "Warning",
experimental: false
},
"prototype-pollution": {
i18n: "sast_warnings.prototype_pollution",
severity: "Warning",
experimental: false
}
}) satisfies Record<WarningName, Pick<Warning, "experimental" | "i18n" | "severity">>;

Expand Down
54 changes: 54 additions & 0 deletions workspaces/js-x-ray/test/probes/isPrototypePollution.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Import Node.js Dependencies
import { test, type TestContext } from "node:test";

// Import Internal Dependencies
import { AstAnalyser } from "../../src/AstAnalyser.ts";

test("should detect prototype pollution via __proto__ property access", (t: TestContext) => {
const str = `
const obj = {};
obj.__proto__.polluted = true;
`;

const analyser = new AstAnalyser();
const { warnings } = analyser.analyse(str);

t.assert.strictEqual(warnings.length, 1);
t.assert.partialDeepStrictEqual(warnings[0], {
kind: "prototype-pollution",
value: "obj.__proto__"
});
});

test("should detect prototype pollution via computed property access", (t: TestContext) => {
const str = `
const obj = {};
obj["__proto__"].polluted = true;
`;

const analyser = new AstAnalyser();
const { warnings } = analyser.analyse(str);

t.assert.strictEqual(warnings.length, 1);
t.assert.partialDeepStrictEqual(warnings[0], {
kind: "prototype-pollution",
value: "obj.__proto__"
});
});

test("should detect prototype pollution via __proto__ literal", (t: TestContext) => {
const str = `
const key = "__proto__";
const obj = {};
obj[key] = true;
`;

const analyser = new AstAnalyser();
const { warnings } = analyser.analyse(str);

t.assert.strictEqual(warnings.length, 1);
t.assert.partialDeepStrictEqual(warnings[0], {
kind: "prototype-pollution",
value: "__proto__"
});
});
Loading