Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,5 @@ jobs:
path: |
build/
library/internals/
library/node_internals/
library/agent/hooks/instrumentation/wasm/
3 changes: 2 additions & 1 deletion library/agent/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { AiSDK } from "../sinks/AiSDK";
import { Mistral } from "../sinks/Mistral";
import { Anthropic } from "../sinks/Anthropic";
import { GoogleGenAi } from "../sinks/GoogleGenAi";
import { Function } from "../sinks/Function";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe rename to FunctionSink to not override global Function in this file?

import type { FetchListsAPI } from "./api/FetchListsAPI";
import { FetchListsAPINodeHTTP } from "./api/FetchListsAPINodeHTTP";
import shouldEnableFirewall from "../helpers/shouldEnableFirewall";
Expand Down Expand Up @@ -168,7 +169,7 @@ export function getWrappers() {
new ClickHouse(),
new Prisma(),
new AwsSDKVersion3(),
// new Function(), Disabled because functionName.constructor === Function is false after patching global
new Function(),
new AwsSDKVersion2(),
new AiSDK(),
new GoogleGenAi(),
Expand Down
4 changes: 4 additions & 0 deletions library/node_internals/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Ignore everything in this directory# Ignore everything in this directory
*
# Except this file
!.gitignore
55 changes: 52 additions & 3 deletions library/sinks/Function.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* oxlint-disable no-implied-eval */
/* oxlint-disable no-eval */
import * as t from "tap";
import { runWithContext, type Context } from "../agent/Context";
import { createTestAgent } from "../helpers/createTestAgent";
Expand Down Expand Up @@ -81,7 +82,7 @@ t.test("it detects JS injections using Function", async (t) => {
if (error instanceof Error) {
t.same(
error.message,
"Zen has blocked a JavaScript injection: new Function(...) originating from body.calc"
"Zen has blocked a JavaScript injection: new Function/eval(...) originating from body.calc"
);
}

Expand All @@ -92,7 +93,7 @@ t.test("it detects JS injections using Function", async (t) => {
if (error2 instanceof Error) {
t.same(
error2.message,
"Zen has blocked a JavaScript injection: new Function(...) originating from body.calc"
"Zen has blocked a JavaScript injection: new Function/eval(...) originating from body.calc"
);
}

Expand All @@ -107,7 +108,55 @@ t.test("it detects JS injections using Function", async (t) => {
if (error3 instanceof Error) {
t.same(
error3.message,
"Zen has blocked a JavaScript injection: new Function(...) originating from body.calc"
"Zen has blocked a JavaScript injection: new Function/eval(...) originating from body.calc"
);
}
});

t.same(eval("1 + 1"), 2);
t.same(eval("const x = 2 + 2; x"), 4);
t.same(eval("(() => 3 * 3)()"), 9);

// Indirect eval
t.same((0, eval)("1 + 1"), 2);
t.same(eval.call(null, "2 + 2"), 4);

runWithContext(safeContext, () => {
t.same(eval("1 + 1"), 2);
t.same(eval("const x = 1+ 1; x"), 2);
});

runWithContext(dangerousContext, () => {
t.same(eval("1 + 1"), 2);
t.same(eval("const x = 1 + 1; x"), 2);

const error4 = t.throws(() => eval("1 + 1; console.log('hello')"));
t.ok(error4 instanceof Error);
if (error4 instanceof Error) {
t.same(
error4.message,
"Zen has blocked a JavaScript injection: new Function/eval(...) originating from body.calc"
);
}

const error5 = t.throws(() =>
eval("const test = 1 + 1; console.log('hello')")
);
t.ok(error5 instanceof Error);
if (error5 instanceof Error) {
t.same(
error5.message,
"Zen has blocked a JavaScript injection: new Function/eval(...) originating from body.calc"
);
}

// Indirect eval should also be blocked
const error6 = t.throws(() => (0, eval)("1 + 1; console.log('hello')"));
t.ok(error6 instanceof Error);
if (error6 instanceof Error) {
t.same(
error6.message,
"Zen has blocked a JavaScript injection: new Function/eval(...) originating from body.calc"
);
}
});
Expand Down
109 changes: 87 additions & 22 deletions library/sinks/Function.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,107 @@
import { join } from "node:path";
import { getInstance } from "../agent/AgentSingleton";
import { getContext } from "../agent/Context";
import { Hooks } from "../agent/hooks/Hooks";
import { InterceptorResult } from "../agent/hooks/InterceptorResult";
import { inspectArgs } from "../agent/hooks/wrapExport";
import { Wrapper } from "../agent/Wrapper";
import { getLibraryRoot } from "../helpers/getLibraryRoot";
import { getMajorNodeVersion } from "../helpers/getNodeVersion";
import { checkContextForJsInjection } from "../vulnerabilities/js-injection/checkContextForJsInjection";
import { existsSync } from "node:fs";

export class Function implements Wrapper {
private inspectFunction(args: any[]) {
const context = getContext();
private inspectFunction(args: unknown[]): InterceptorResult {
if (args.length === 0) {
return undefined;
}

if (!context || !Array.isArray(args) || args.length === 0) {
const code = args[0];
if (!code || typeof code !== "string") {
return undefined;
}

const findLastStringArg = (args: any[]) => {
for (let i = args.length - 1; i >= 0; --i) {
if (typeof args[i] === "string") {
return args[i];
}
}
const context = getContext();
if (!context) {
return undefined;
};
}

return checkContextForJsInjection({
js: code,
operation: "new Function/eval",
context,
});
}

const lastStringArg = findLastStringArg(args);
private loadNativeAddon() {
const majorVersion = getMajorNodeVersion();
const arch = process.arch;
const platform = process.platform;

if (lastStringArg) {
return checkContextForJsInjection({
js: lastStringArg,
operation: "new Function",
context,
});
const nodeInternalsDir = join(getLibraryRoot(), "node_internals");
const binaryPath = join(
nodeInternalsDir,
`zen-internals-node-${platform}-${arch}-node${majorVersion}.node`
);
if (!existsSync(binaryPath)) {
// oxlint-disable-next-line no-console
console.warn(
`AIKIDO: Cannot find native addon for Node.js ${majorVersion} on ${platform}-${arch}. Function sink will not be instrumented.`
);
return;
}

return undefined;
const bindings: {
setCodeGenerationCallback: (
callback: (code: string) => string | undefined
) => void;
} = require(binaryPath);
if (!bindings || typeof bindings.setCodeGenerationCallback !== "function") {
// oxlint-disable-next-line no-console
console.warn(
`AIKIDO: Native addon for Node.js ${majorVersion} on ${platform}-${arch} is invalid. Function sink will not be instrumented.`
);
return;
}

return bindings;
}

wrap(hooks: Hooks) {
hooks.addGlobal("Function", {
kind: "eval_op",
inspectArgs: this.inspectFunction,
wrap(_: Hooks) {
const bindings = this.loadNativeAddon();
if (!bindings) {
return;
}

bindings.setCodeGenerationCallback((code: string) => {
const agent = getInstance();
if (!agent) {
return;
}

const context = getContext();
if (!context) {
return;
}

try {
inspectArgs(
[code],
this.inspectFunction,
context,
agent,
{
name: "Function/eval",
type: "global",
},
"<compile>",
"eval_op"
);
} catch (error) {
// In blocking mode, onInspectionInterceptorResult would throw to block the operation
// To block the code generation, we need to return a string that will be used for the thrown error message
return (error as Error).message;
}
});
}
}
58 changes: 57 additions & 1 deletion scripts/build.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const { rm, copyFile, mkdir, readFile, writeFile } = require("fs/promises");
const { rm, copyFile, cp, mkdir, readFile, writeFile } = require("fs/promises");
const { join } = require("path");
const { exec } = require("child_process");
const { fileExists, findFilesWithExtension } = require("./helpers/fs");
Expand Down Expand Up @@ -27,10 +27,18 @@ const INTERNALS_VERSION = "v0.1.56";
const INTERNALS_URL = `https://github.com/AikidoSec/zen-internals/releases/download/${INTERNALS_VERSION}`;
// ---

// Node Internals configuration
const NODE_INTERNALS_VERSION = "1.0.0";
const NODE_INTERNALS_URL = `https://github.com/AikidoSec/zen-internals-node/releases/download/${NODE_INTERNALS_VERSION}`;
// 17 is not included on purpose
const NODE_VERSIONS = [16, 18, 19, 20, 21, 22, 23, 24, 25];
// ---

const rootDir = join(__dirname, "..");
const buildDir = join(rootDir, "build");
const libDir = join(rootDir, "library");
const internalsDir = join(libDir, "internals");
const nodeInternalsDir = join(libDir, "node_internals");
const instrumentationWasmDir = join(rootDir, "instrumentation-wasm");
const instrumentationWasmOutDir = join(
libDir,
Expand All @@ -48,6 +56,7 @@ async function main() {

await dlZenInternals();
await buildInstrumentationWasm();
await dlNodeInternals();

if (process.argv.includes("--only-wasm")) {
console.log("Built only WASM files as requested.");
Expand All @@ -73,6 +82,9 @@ async function main() {
join(internalsDir, "zen_internals_bg.wasm"),
join(buildDir, "internals", "zen_internals_bg.wasm")
);
await cp(nodeInternalsDir, join(buildDir, "node_internals"), {
recursive: true,
});
await copyFile(
join(instrumentationWasmOutDir, "node_code_instrumentation_bg.wasm"),
join(
Expand All @@ -91,6 +103,50 @@ async function main() {
process.exit(0);
}

async function dlNodeInternals() {
await mkdir(nodeInternalsDir, { recursive: true });

// Check if the wanted version of Node Internals is already installed
const versionCacheFile = join(nodeInternalsDir, ".installed_version");
const installedVersion = (await fileExists(versionCacheFile))
? await readFile(versionCacheFile, "utf8")
: null;
if (installedVersion === NODE_INTERNALS_VERSION) {
console.log("Node Internals already installed. Skipping download.");
return;
}

const downloads = [];
for (const nodeVersion of NODE_VERSIONS) {
for (const platform of ["linux", "darwin", "win32"]) {
let archs = ["x64", "arm64"];
if (platform === "win32") {
// Only x64 builds are available for Windows
archs = ["x64"];
}
if (nodeVersion === 16) {
// Only x64 builds are available for Node 16
archs = ["x64"];
}
for (const arch of archs) {
// zen-internals-node-linux-x64-node20.node
const filename = `zen-internals-node-${platform}-${arch}-node${nodeVersion}.node`;
const url = `${NODE_INTERNALS_URL}/${filename}`;
const destPath = join(nodeInternalsDir, filename);

console.log(
`Downloading Node Internals for Node ${nodeVersion} ${platform} ${arch}...`
);
downloads.push(downloadFile(url, destPath));
}
}
}

await Promise.all(downloads);

await writeFile(versionCacheFile, NODE_INTERNALS_VERSION);
}

// Download Zen Internals tarball and verify checksum
async function dlZenInternals() {
const tarballFile = "zen_internals.tgz";
Expand Down
Loading