Skip to content

Commit 8a7b7c4

Browse files
committed
feat: implement new default isMonkeyPatch probe with monkey-patch warning
1 parent 996be20 commit 8a7b7c4

File tree

9 files changed

+192
-14
lines changed

9 files changed

+192
-14
lines changed

.changeset/sparkly-pugs-doubt.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nodesecure/js-x-ray": minor
3+
---
4+
5+
Implement a new monkey-patch warning/probe

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ type WarningName = "parsing-error"
107107
| "shady-link"
108108
| "synchronous-io"
109109
| "log-usage"
110-
| "serialize-environment";
110+
| "serialize-environment"
111+
| "monkey-patch";
111112

112113
declare const warnings: Record<WarningName, {
113114
i18n: string;
@@ -148,7 +149,7 @@ This section describes all the possible warnings returned by JSXRay. Click on th
148149
| [data-exfiltration](./docs/data-exfiltration.md) || the code potentially attemps to transfer sensitive data wihtout authorization from a computer or network to an external location. |
149150
| [log-usage](./docs/log-usage.md) || The code contains a log call. |
150151
| [sql-injection](./docs/sql-injection.md) || The code contains a SQL injection vulnerability |
151-
152+
| [monkey-patch](./docs/monkey-patch.md) || The code override a native JavaScript prototype |
152153

153154
## Workspaces
154155

docs/monkey-patch.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Monkey patch
2+
3+
| Code | Severity | i18n | Experimental |
4+
| --- | --- | --- | :-: |
5+
| monkey-patch | `Warning` | `sast_warnings.monkey_patch` ||
6+
7+
## Introduction
8+
9+
Monkey-patching involves modifying native language objects (prototypes, global functions) at runtime to alter their behavior. While it can serve legitimate purposes like polyfills or extending APIs, it introduces significant security risks: breaking invariants, global side effects, flow hijacking (hooking), stealthy persistence, and concealing malicious activities.
10+
11+
JS-X-Ray raises a `monkey-patch` warning when it detects writes to native prototypes. The signal is intentionally broad to facilitate review: while some legitimate uses exist, any invasive modification deserves inspection.
12+
13+
## Examples
14+
15+
```js
16+
Array.prototype.map = function() {
17+
// alters global map() behavior
18+
};
19+
20+
Object.defineProperty(String.prototype, "replace", {
21+
configurable: true,
22+
enumerable: false,
23+
writable: true,
24+
value: function replacer(search, replaceWith) {
25+
// systematic interception of all replace() calls
26+
}
27+
});
28+
```

workspaces/js-x-ray/src/ProbeRunner.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import isSyncIO from "./probes/isSyncIO.ts";
2626
import isUnsafeCallee from "./probes/isUnsafeCallee.ts";
2727
import isUnsafeCommand from "./probes/isUnsafeCommand.ts";
2828
import isWeakCrypto from "./probes/isWeakCrypto.ts";
29+
import isMonkeyPatch from "./probes/isMonkeyPatch.ts";
2930

3031
import type { SourceFile } from "./SourceFile.ts";
3132
import type { OptionalWarningName } from "./warnings.ts";
@@ -100,7 +101,8 @@ export class ProbeRunner {
100101
isUnsafeCommand,
101102
isSerializeEnv,
102103
dataExfiltration,
103-
sqlInjection
104+
sqlInjection,
105+
isMonkeyPatch
104106
];
105107

106108
static Optionals: Record<OptionalWarningName, Probe> = {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Import Third-party Dependencies
2+
import type { ESTree } from "meriyah";
3+
import { getMemberExpressionIdentifier } from "@nodesecure/estree-ast-utils";
4+
5+
// Import Internal Dependencies
6+
import type {
7+
ProbeMainContext,
8+
ProbeContext
9+
} from "../ProbeRunner.ts";
10+
import { generateWarning } from "../warnings.ts";
11+
12+
// CONSTANTS
13+
export const JS_TYPES = new Set([
14+
"AggregateError",
15+
"Array",
16+
"ArrayBuffer",
17+
"BigInt",
18+
"BigInt64Array",
19+
"BigUint64Array",
20+
"Boolean",
21+
"DataView",
22+
"Date",
23+
"Error",
24+
"EvalError",
25+
"FinalizationRegistry",
26+
"Float32Array",
27+
"Float64Array",
28+
"Function",
29+
"Int16Array",
30+
"Int32Array",
31+
"Int8Array",
32+
"Map",
33+
"Number",
34+
"Object",
35+
"Promise",
36+
"Proxy",
37+
"RangeError",
38+
"ReferenceError",
39+
"Reflect",
40+
"RegExp",
41+
"Set",
42+
"SharedArrayBuffer",
43+
"String",
44+
"Symbol",
45+
"SyntaxError",
46+
"TypeError",
47+
"Uint16Array",
48+
"Uint32Array",
49+
"Uint8Array",
50+
"Uint8ClampedArray",
51+
"URIError",
52+
"WeakMap",
53+
"WeakRef",
54+
"WeakSet"
55+
]);
56+
57+
/**
58+
* @description Search for monkey patching of built-in prototypes.
59+
* @example
60+
* Array.prototype.map = function() {};
61+
*/
62+
function validateNode(
63+
node: ESTree.Node,
64+
ctx: ProbeContext
65+
): [boolean, any?] {
66+
const tracer = ctx.sourceFile.tracer;
67+
68+
if (
69+
node.type !== "AssignmentExpression" ||
70+
node.left.type !== "MemberExpression"
71+
) {
72+
return [false];
73+
}
74+
75+
const iter = getMemberExpressionIdentifier(node.left, {
76+
externalIdentifierLookup: (name: string) => tracer.literalIdentifiers.get(name) ?? null
77+
});
78+
79+
const jsTypeName = iter.next().value;
80+
if (typeof jsTypeName !== "string" || !JS_TYPES.has(jsTypeName)) {
81+
return [false];
82+
}
83+
84+
return [
85+
iter.next().value === "prototype",
86+
`${jsTypeName}.prototype`
87+
];
88+
}
89+
90+
function main(
91+
node: ESTree.Node,
92+
options: ProbeMainContext
93+
) {
94+
const { sourceFile, data: prototypeName } = options;
95+
96+
sourceFile.warnings.push(
97+
generateWarning("monkey-patch", { value: prototypeName, location: node.loc })
98+
);
99+
}
100+
101+
export default {
102+
name: "isMonkeyPatch",
103+
validateNode,
104+
main
105+
};

workspaces/js-x-ray/src/warnings.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
} from "./utils/toArrayLocation.ts";
1111

1212
export type OptionalWarningName =
13-
| "synchronous-io" | "log-usage";
13+
| "synchronous-io"
14+
| "log-usage";
1415

1516
export type WarningName =
1617
| "parsing-error"
@@ -28,6 +29,7 @@ export type WarningName =
2829
| "serialize-environment"
2930
| "data-exfiltration"
3031
| "sql-injection"
32+
| "monkey-patch"
3133
| OptionalWarningName;
3234

3335
export interface Warning<T = WarningName> {
@@ -126,6 +128,11 @@ export const warnings = Object.freeze({
126128
i18n: "sast_warnings.sql_injection",
127129
severity: "Warning",
128130
experimental: false
131+
},
132+
"monkey-patch": {
133+
i18n: "sast_warnings.monkey_patch",
134+
severity: "Warning",
135+
experimental: false
129136
}
130137
}) satisfies Record<WarningName, Pick<Warning, "experimental" | "i18n" | "severity">>;
131138

workspaces/js-x-ray/test/probes/isLiteral.spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,9 @@ describe("email collection", () => {
296296

297297
const emails = Array.from(emailSet);
298298
assert.strictEqual(emails.length, 3);
299-
assert.ok(emails.some(e => e.value === "[email protected]"));
300-
assert.ok(emails.some(e => e.value === "[email protected]"));
301-
assert.ok(emails.some(e => e.value === "[email protected]"));
299+
assert.ok(emails.some((e) => e.value === "[email protected]"));
300+
assert.ok(emails.some((e) => e.value === "[email protected]"));
301+
assert.ok(emails.some((e) => e.value === "[email protected]"));
302302
});
303303

304304
test("should not collect invalid email formats", () => {
@@ -311,7 +311,7 @@ describe("email collection", () => {
311311
`;
312312
const ast = parseScript(str);
313313
const emailSet = new CollectableSet("email");
314-
const sastAnalysis = getSastAnalysis(isLiteral, { collectables: [emailSet] })
314+
getSastAnalysis(isLiteral, { collectables: [emailSet] })
315315
.execute(ast.body);
316316

317317
const emails = Array.from(emailSet);
@@ -327,21 +327,21 @@ describe("email collection", () => {
327327
`;
328328
const ast = parseScript(str);
329329
const emailSet = new CollectableSet("email");
330-
const sastAnalysis = getSastAnalysis(isLiteral, { collectables: [emailSet] })
330+
getSastAnalysis(isLiteral, { collectables: [emailSet] })
331331
.execute(ast.body);
332332

333333
const emails = Array.from(emailSet);
334334
assert.strictEqual(emails.length, 3);
335-
assert.ok(emails.some(e => e.value === "[email protected]"));
336-
assert.ok(emails.some(e => e.value === "[email protected]"));
337-
assert.ok(emails.some(e => e.value === "[email protected]"));
335+
assert.ok(emails.some((e) => e.value === "[email protected]"));
336+
assert.ok(emails.some((e) => e.value === "[email protected]"));
337+
assert.ok(emails.some((e) => e.value === "[email protected]"));
338338
});
339339

340340
test("should track email locations correctly", () => {
341341
const str = `const email = "[email protected]";`;
342342
const ast = parseScript(str);
343343
const emailSet = new CollectableSet("email");
344-
const sastAnalysis = getSastAnalysis(isLiteral, { collectables: [emailSet], location: "test.js" })
344+
getSastAnalysis(isLiteral, { collectables: [emailSet], location: "test.js" })
345345
.execute(ast.body);
346346

347347
const emails = Array.from(emailSet);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
2+
// Import Node.js Dependencies
3+
import { describe, test } from "node:test";
4+
5+
// Import Internal Dependencies
6+
import isMonkeyPatch, { JS_TYPES } from "../../src/probes/isMonkeyPatch.ts";
7+
import { getSastAnalysis, parseScript } from "../utils/index.ts";
8+
9+
describe("monkey-patch", () => {
10+
test("should detect re-assignment of prototype methods", (t) => {
11+
t.plan(JS_TYPES.size * 2);
12+
13+
for (const jsType of JS_TYPES) {
14+
const computed = Math.random() > 0.5 ? `["prototype"]` : ".prototype";
15+
const str = `${jsType}${computed}.any = function() {};`;
16+
17+
const ast = parseScript(str);
18+
const sastAnalysis = getSastAnalysis(isMonkeyPatch);
19+
sastAnalysis.execute(ast);
20+
21+
const outputWarnings = sastAnalysis.warnings();
22+
23+
t.assert.equal(outputWarnings.length, 1);
24+
t.assert.partialDeepStrictEqual(outputWarnings[0], {
25+
kind: "monkey-patch",
26+
value: `${jsType}.prototype`
27+
});
28+
}
29+
});
30+
});

workspaces/js-x-ray/test/probes/log-usage.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { AstAnalyser } from "../../src/index.ts";
1010
// CONSTANTS
1111
const kFixtureURL = new URL("fixtures/logUsage/", import.meta.url);
1212

13-
test("it should detect log methods", async () => {
13+
test("it should detect log methods", async() => {
1414
const fixtureFiles = await fs.readdir(kFixtureURL);
1515
for (const fixtureFile of fixtureFiles) {
1616
const fixture = readFileSync(new URL(fixtureFile, kFixtureURL), "utf-8");

0 commit comments

Comments
 (0)