Skip to content

Commit 857308c

Browse files
authored
feat(probes): add minimal implementation of data exfiltration (#399)
1 parent b6d2474 commit 857308c

File tree

15 files changed

+327
-2
lines changed

15 files changed

+327
-2
lines changed

.changeset/chatty-eagles-draw.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+
feat(probes) add minimal implementation of data-exfiltration

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ This section describes all the possible warnings returned by JSXRay. Click on th
144144
| [shady-link](./docs/shady-link.md) || The code contains a shady/unsafe link |
145145
| [synchronous-io](./docs/synchronous-io.md) | ✔️ | The code contains a synchronous IO call. |
146146
| [serialize-environment](./docs/serialize-environment.md) || The code attempts to serialize process.env which could lead to environment variable exfiltration |
147+
| [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. |
148+
147149

148150
## Workspaces
149151

docs/data-exfiltration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Data exfiltration
2+
3+
| Code | Severity | i18n | Experimental |
4+
| --- | --- | --- | :-: |
5+
| data-exfiltration | `Warning` | `sast_warnings.data_exfiltration` ||
6+
7+
## Introduction
8+
9+
Data exfiltration is the unauthorized transfer of sensitive data from a computer or network to an external location. This can occur through malicious code, insider threats, or compromised systems.
10+
11+
## Example
12+
13+
```js
14+
import os from "os";
15+
16+
JSON.stringify(os.userInfo());
17+
```

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import isFetch from "./probes/isFetch.js";
1919
import isUnsafeCommand from "./probes/isUnsafeCommand.js";
2020
import isSyncIO from "./probes/isSyncIO.js";
2121
import isSerializeEnv from "./probes/isSerializeEnv.js";
22+
import dataExfiltration from "./probes/data-exfiltration.js";
2223

2324
import type { SourceFile } from "./SourceFile.js";
2425
import type { OptionalWarningName } from "./warnings.js";
@@ -83,7 +84,8 @@ export class ProbeRunner {
8384
isBinaryExpression,
8485
isArrayExpression,
8586
isUnsafeCommand,
86-
isSerializeEnv
87+
isSerializeEnv,
88+
dataExfiltration
8789
];
8890

8991
static Optionals: Record<OptionalWarningName, Probe> = {
@@ -113,8 +115,9 @@ export class ProbeRunner {
113115
const isDefined = Reflect.defineProperty(probe, kProbeOriginalContext, {
114116
enumerable: false,
115117
value: structuredClone(probe.context),
116-
writable: false
118+
configurable: true
117119
});
120+
118121
if (!isDefined) {
119122
throw new Error(`Failed to define original context for probe '${probe.name}'`);
120123
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Import Third-party Dependencies
2+
import {
3+
getCallExpressionIdentifier
4+
} from "@nodesecure/estree-ast-utils";
5+
import type { ESTree } from "meriyah";
6+
7+
// Import Internal Dependencies
8+
import { generateWarning } from "../warnings.js";
9+
import type { ProbeContext } from "../ProbeRunner.js";
10+
import { rootLocation, toArrayLocation, type SourceArrayLocation } from "../utils/toArrayLocation.js";
11+
12+
// CONSTANTS
13+
const kSensitiveMethods = [
14+
"os.userInfo",
15+
"os.networkInterfaces",
16+
"os.cpus",
17+
"dns.getServers"
18+
];
19+
20+
type DataExfiltrationContextDef = Record<string, SourceArrayLocation[]>;
21+
22+
function validateNode(
23+
node: ESTree.Node,
24+
ctx: ProbeContext
25+
): [boolean, any?] {
26+
const tracer = ctx.sourceFile.tracer;
27+
const id = getCallExpressionIdentifier(node);
28+
29+
if (id === null) {
30+
return [false];
31+
}
32+
const data = tracer.getDataFromIdentifier(id);
33+
34+
if (data === null || data.identifierOrMemberExpr !== "JSON.stringify") {
35+
return [false];
36+
}
37+
38+
const castedNode = node as ESTree.CallExpression;
39+
if (castedNode.arguments.length === 0) {
40+
return [false];
41+
}
42+
43+
return [true];
44+
}
45+
46+
function main(
47+
node: ESTree.CallExpression,
48+
ctx: ProbeContext<DataExfiltrationContextDef>
49+
) {
50+
const { sourceFile } = ctx;
51+
52+
const firstArg = node.arguments[0];
53+
if (firstArg.type !== "CallExpression") {
54+
return;
55+
}
56+
const id = getCallExpressionIdentifier(firstArg)!;
57+
const data = sourceFile.tracer.getDataFromIdentifier(id);
58+
if (kSensitiveMethods.some((method) => data?.identifierOrMemberExpr === method
59+
&& sourceFile.tracer.importedModules.has(method.split(".")[0]))) {
60+
const arrayLocation = ctx.context?.[data?.identifierOrMemberExpr!];
61+
if (arrayLocation) {
62+
arrayLocation.push(toArrayLocation(firstArg.loc ?? rootLocation()));
63+
}
64+
else {
65+
ctx.context![data?.identifierOrMemberExpr!] = [toArrayLocation(firstArg.loc ?? rootLocation())];
66+
}
67+
}
68+
}
69+
70+
function initialize(
71+
ctx: ProbeContext<DataExfiltrationContextDef>
72+
) {
73+
const { sourceFile: { tracer } } = ctx;
74+
tracer
75+
.trace("JSON.stringify", {
76+
followConsecutiveAssignment: true
77+
}).trace("os.userInfo", {
78+
moduleName: "os",
79+
followConsecutiveAssignment: true
80+
}).trace("os.networkInterfaces", {
81+
moduleName: "os",
82+
followConsecutiveAssignment: true
83+
}).trace("os.cpus", {
84+
moduleName: "os",
85+
followConsecutiveAssignment: true
86+
}).trace("dns.getServers", {
87+
moduleName: "dns",
88+
followConsecutiveAssignment: true
89+
});
90+
}
91+
92+
function finalize(ctx: ProbeContext<DataExfiltrationContextDef>) {
93+
const { sourceFile, context } = ctx;
94+
if (context && Object.keys(context).length > 0) {
95+
const warning = generateWarning("data-exfiltration",
96+
{ value: Object.keys(context).join(", ") });
97+
sourceFile.warnings.push({ ...warning, location: Object.values(context).flat() });
98+
}
99+
}
100+
101+
const dateExifiltration = {
102+
name: "dataExfiltration",
103+
validateNode,
104+
initialize,
105+
finalize,
106+
main,
107+
breakOnMatch: false,
108+
context: {}
109+
};
110+
111+
export default dateExifiltration;

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type WarningName =
2626
| "unsafe-command"
2727
| "unsafe-import"
2828
| "serialize-environment"
29+
| "data-exfiltration"
2930
| OptionalWarningName;
3031

3132
export interface Warning<T = WarningName> {
@@ -103,6 +104,11 @@ export const warnings = Object.freeze({
103104
i18n: "sast_warnings.serialize_environment",
104105
severity: "Warning",
105106
experimental: false
107+
},
108+
"data-exfiltration": {
109+
i18n: "sast_warnings.data_exfiltration",
110+
severity: "Warning",
111+
experimental: false
106112
}
107113
}) satisfies Record<WarningName, Pick<Warning, "experimental" | "i18n" | "severity">>;
108114

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Import Node.js Dependencies
2+
import { test, describe } from "node:test";
3+
import assert from "node:assert";
4+
import { readFileSync } from "node:fs";
5+
import fs from "node:fs/promises";
6+
7+
// Import Internal Dependencies
8+
import { AstAnalyser } from "../../src/index.js";
9+
10+
const FIXTURE_URL = new URL("fixtures/dataExfiltration/", import.meta.url);
11+
12+
describe("data exfiltration", () => {
13+
test("it should report a warning in case of `JSON.stringify(sensitiveData) for member expression`", async() => {
14+
const fixturesDir = new URL("memberExpression/", FIXTURE_URL);
15+
const fixtureFiles = await fs.readdir(fixturesDir);
16+
17+
for (const fixtureFile of fixtureFiles) {
18+
const fixture = readFileSync(new URL(fixtureFile, fixturesDir), "utf-8");
19+
const { warnings: outputWarnings } = new AstAnalyser(
20+
{
21+
optionalWarnings: true
22+
}
23+
).analyse(fixture);
24+
25+
const [firstWarning] = outputWarnings;
26+
assert.strictEqual(outputWarnings.length, 1);
27+
assert.deepEqual(firstWarning.kind, "data-exfiltration");
28+
assert.strictEqual(firstWarning.value, `${fixtureFile.split(".").slice(0, 2).join(".")}`);
29+
}
30+
});
31+
32+
test("it should report a warning in case of `JSON.stringify(sensitiveData) for direct call expression`", async() => {
33+
const fixturesDir = new URL("directCallExpression/", FIXTURE_URL);
34+
const fixtureFiles = await fs.readdir(fixturesDir);
35+
36+
for (const fixtureFile of fixtureFiles) {
37+
const fixture = readFileSync(new URL(fixtureFile, fixturesDir), "utf-8");
38+
const { warnings: outputWarnings } = new AstAnalyser(
39+
{
40+
optionalWarnings: true
41+
}
42+
).analyse(fixture);
43+
44+
const [firstWarning] = outputWarnings;
45+
assert.strictEqual(outputWarnings.length, 1);
46+
assert.deepEqual(firstWarning.kind, "data-exfiltration");
47+
assert.strictEqual(firstWarning.value, `${fixtureFile.split(".").slice(0, 2).join(".")}`);
48+
}
49+
});
50+
51+
test("should only generate one warning when multiple detection of data exfiltration occurs", () => {
52+
const code = `
53+
import os from "os";
54+
55+
JSON.stringify(os.userInfo());
56+
JSON.stringify(os.userInfo());
57+
JSON.stringify(os.networkInterfaces());
58+
`;
59+
60+
const { warnings: outputWarnings } = new AstAnalyser(
61+
{
62+
optionalWarnings: true
63+
}
64+
).analyse(code);
65+
66+
const [firstWarning] = outputWarnings;
67+
assert.strictEqual(outputWarnings.length, 1);
68+
assert.deepEqual(firstWarning.kind, "data-exfiltration");
69+
assert.strictEqual(firstWarning.value, "os.userInfo, os.networkInterfaces");
70+
assert.strictEqual(firstWarning.location?.length, 3);
71+
});
72+
73+
test("should not generate a warning when serializing return value of every function call", () => {
74+
const code = `
75+
function foo (){
76+
return "foo";
77+
}
78+
JSON.stringify(foo());
79+
`;
80+
const { warnings: outputWarnings } = new AstAnalyser(
81+
{
82+
optionalWarnings: true
83+
}
84+
).analyse(code);
85+
86+
assert.strictEqual(outputWarnings.length, 0);
87+
});
88+
89+
test("should not generate a warning when os is not imported", () => {
90+
const code = `
91+
const os = {
92+
userInfo(){
93+
return {};
94+
},
95+
cpus(){
96+
return [];
97+
},
98+
networkInterfaces(){
99+
return [];
100+
}
101+
102+
}
103+
JSON.stringify(os.userInfo());
104+
JSON.stringify(os.networkInterfaces());
105+
JSON.stringify(os.cpus());
106+
`;
107+
const { warnings: outputWarnings } = new AstAnalyser(
108+
{
109+
optionalWarnings: true
110+
}
111+
).analyse(code);
112+
113+
assert.strictEqual(outputWarnings.length, 0);
114+
});
115+
116+
test("should not generate a warning when dns is not imported", () => {
117+
const code = `
118+
const dns = {
119+
getServers(){
120+
return [];
121+
}
122+
}
123+
JSON.stringify(getServers());
124+
`;
125+
const { warnings: outputWarnings } = new AstAnalyser(
126+
{
127+
optionalWarnings: true
128+
}
129+
).analyse(code);
130+
131+
assert.strictEqual(outputWarnings.length, 0);
132+
});
133+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const { getServers } = require("dns");
2+
3+
const stringify = JSON.stringify;
4+
5+
stringify(getServers());
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const { cpus } = require("os");
2+
3+
const stringify = JSON.stringify;
4+
5+
stringify(cpus());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const { networkInterfaces } = require("os");
2+
3+
const stringify = JSON.stringify;
4+
5+
stringify(networkInterfaces());

0 commit comments

Comments
 (0)