Skip to content

Commit 8f1768d

Browse files
committed
feat(probes): add minimal implementation of data exfiltration
1 parent 283d5b6 commit 8f1768d

File tree

14 files changed

+322
-1
lines changed

14 files changed

+322
-1
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

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: 3 additions & 1 deletion
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";
@@ -80,7 +81,8 @@ export class ProbeRunner {
8081
isBinaryExpression,
8182
isArrayExpression,
8283
isUnsafeCommand,
83-
isSerializeEnv
84+
isSerializeEnv,
85+
dataExfiltration
8486
];
8587

8688
static Optionals: Record<OptionalWarningName, Probe> = {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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 DataExfiltrationCtx = Map<string, SourceArrayLocation[]>;
21+
22+
function validateNode(
23+
node: ESTree.Node,
24+
ctx: ProbeContext<DataExfiltrationCtx>
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.Node,
48+
ctx: ProbeContext<DataExfiltrationCtx>
49+
) {
50+
const castedNode = node as ESTree.CallExpression;
51+
const { sourceFile } = ctx;
52+
53+
const firstArg = castedNode.arguments[0];
54+
if (firstArg.type !== "CallExpression") {
55+
return;
56+
}
57+
const id = getCallExpressionIdentifier(firstArg)!;
58+
const data = sourceFile.tracer.getDataFromIdentifier(id);
59+
if (kSensitiveMethods.some((method) => data?.identifierOrMemberExpr === method
60+
&& sourceFile.tracer.importedModules.has(method.split(".")[0]))) {
61+
const arrayLocation = ctx.context?.get(data?.identifierOrMemberExpr!);
62+
if (arrayLocation) {
63+
arrayLocation.push(toArrayLocation(firstArg.loc ?? rootLocation()));
64+
}
65+
else {
66+
ctx.context?.set(data?.identifierOrMemberExpr!, [toArrayLocation(firstArg.loc ?? rootLocation())]);
67+
}
68+
}
69+
}
70+
71+
function initialize(
72+
{ sourceFile: { tracer } }: ProbeContext<DataExfiltrationCtx>
73+
) {
74+
tracer.trace("JSON.stringify", {
75+
followConsecutiveAssignment: true
76+
}).trace("os.userInfo", {
77+
moduleName: "os",
78+
followConsecutiveAssignment: true
79+
}).trace("os.networkInterfaces", {
80+
moduleName: "os",
81+
followConsecutiveAssignment: true
82+
}).trace("os.cpus", {
83+
moduleName: "os",
84+
followConsecutiveAssignment: true
85+
})
86+
.trace("dns.getServers", {
87+
moduleName: "dns",
88+
followConsecutiveAssignment: true
89+
});
90+
}
91+
92+
function finalize(ctx: ProbeContext<DataExfiltrationCtx>) {
93+
const { sourceFile, context } = ctx;
94+
if (context?.size) {
95+
const warning = generateWarning("data-exfiltration",
96+
{ value: [...context.keys()].join(", ") });
97+
sourceFile.warnings.push({ ...warning, location: [...context.values()].flat() });
98+
context.clear();
99+
}
100+
}
101+
102+
export default {
103+
name: "dataExfiltration",
104+
validateNode,
105+
initialize,
106+
finalize,
107+
main,
108+
breakOnMatch: false,
109+
context: new Map()
110+
};

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());
Lines changed: 5 additions & 0 deletions
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());
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const { userInfo } = require("os");
2+
3+
const stringify = JSON.stringify;
4+
5+
stringify(userInfo());

0 commit comments

Comments
 (0)