Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add end2end test for apps bundled with esbuild #459

Merged
merged 10 commits into from
Nov 28, 2024
Merged
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
35 changes: 35 additions & 0 deletions docs/esbuild.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Installing Zen in a Node.js Application Bundled with esbuild

Note: Zen runs only on the server side, it does not run in the browser.

Note: If `bundle` is set to `false` in the esbuild configuration, Zen will work without any additional configuration.

Modify your esbuild configuration to include the external option using this utility:

```js
const { build } = require("esbuild");
const { externals } = require("@aikidosec/firewall/bundler"); // <-- Add this line

build({
entryPoints: ["./app.js"],
bundle: true,
platform: "node",
target: "node18",
outfile: "./dist/app.js",
external: externals(), // <-- Add this line
});
```

This tells esbuild to exclude @aikidosec/firewall and any packages that Zen hooks into from the bundle.

⚠️ Don't forget to copy the node_modules directory to the output directory.

## Why do I need to do this?

Zen works by intercepting `require()` calls that a Node.js application makes when loading modules. This includes modules that are built-in to Node.js, like the `fs` module for accessing the filesystem, as well as modules installed from the NPM registry, like the `pg` database module.

Bundlers like esbuild crawl all of the `require()` calls that an application makes to files on disk. It replaces the `require()` calls with custom code and combines all the resulting JavaScript into one "bundled" file. When a built-in module is loaded, such as `require('fs')`, that call can then remain the same in the resulting bundle.

Zen can continue to intercept the calls for built-in modules but cannot intercept calls to third party libraries under those conditions. This means that when you bundle a Zen app with a bundler Zen is likely to capture information about disk access (through `fs`) and outbound HTTP requests (through `http`), but omit calls to third party libraries.

The solution is to treat all third party modules that Zen needs to instrument as being "external" to the bundler. With this setting the instrumented modules remain on disk and continue to be loaded with `require()` while the non-instrumented modules are bundled.
260 changes: 138 additions & 122 deletions end2end/tests/express-postgres.test.js
Original file line number Diff line number Diff line change
@@ -1,153 +1,169 @@
const t = require("tap");
const { spawn } = require("child_process");
const { spawn, spawnSync } = require("child_process");
const { resolve } = require("path");
const timeout = require("../timeout");

const pathToApp = resolve(
__dirname,
"../../sample-apps/express-postgres",
"app.js"
);
const directory = resolve(__dirname, "../../sample-apps/express-postgres");

t.test("it blocks in blocking mode", (t) => {
const server = spawn(`node`, ["--preserve-symlinks", pathToApp, "4000"], {
env: { ...process.env, AIKIDO_DEBUG: "true", AIKIDO_BLOCKING: "true" },
});
const entrypoints = ["app.js", "compiled.js"];

server.on("close", () => {
t.end();
t.before(() => {
const { stderr } = spawnSync("node", ["esbuild.js"], {
cwd: directory,
});

server.on("error", (err) => {
t.fail(err.message);
});
if (stderr && stderr.toString().length > 0) {
throw new Error(`Failed to build: ${stderr.toString()}`);
}
});

let stdout = "";
server.stdout.on("data", (data) => {
stdout += data.toString();
});
entrypoints.forEach((entrypoint) => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Existing tests are run twice, for the normal app.js and once for compiled.js

t.test(`it blocks in blocking mode (${entrypoint})`, (t) => {
const server = spawn(`node`, ["--preserve-symlinks", entrypoint, "4000"], {
env: { ...process.env, AIKIDO_DEBUG: "true", AIKIDO_BLOCKING: "true" },
cwd: directory,
});

let stderr = "";
server.stderr.on("data", (data) => {
stderr += data.toString();
});
server.on("close", () => {
t.end();
});

// Wait for the server to start
timeout(2000)
.then(() => {
return Promise.all([
fetch(
`http://localhost:4000/?petname=${encodeURIComponent("Njuska'); DELETE FROM cats_2;-- H")}`,
{
server.on("error", (err) => {
t.fail(err);
});

let stdout = "";
server.stdout.on("data", (data) => {
stdout += data.toString();
});

let stderr = "";
server.stderr.on("data", (data) => {
stderr += data.toString();
});

// Wait for the server to start
timeout(2000)
.then(() => {
return Promise.all([
fetch(
`http://localhost:4000/?petname=${encodeURIComponent("Njuska'); DELETE FROM cats_2;-- H")}`,
{
signal: AbortSignal.timeout(5000),
}
),
fetch(`http://localhost:4000/string-concat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ petname: ["'", "1)", "(0,1)", "(1", "'"] }),
signal: AbortSignal.timeout(5000),
}
),
fetch(`http://localhost:4000/string-concat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ petname: ["'", "1)", "(0,1)", "(1", "'"] }),
signal: AbortSignal.timeout(5000),
}),
fetch(
`http://localhost:4000/string-concat?petname='&petname=1)&petname=(0,1)&petname=(1&petname='`,
{
}),
fetch(
`http://localhost:4000/string-concat?petname='&petname=1)&petname=(0,1)&petname=(1&petname='`,
{
signal: AbortSignal.timeout(5000),
}
),
fetch("http://localhost:4000/?petname=Njuska", {
signal: AbortSignal.timeout(5000),
}
),
fetch("http://localhost:4000/?petname=Njuska", {
signal: AbortSignal.timeout(5000),
}),
]);
})
.then(
async ([sqlInjection, sqlInjection2, sqlInjection3, normalSearch]) => {
t.equal(sqlInjection.status, 500);
t.equal(sqlInjection2.status, 500);
t.equal(sqlInjection3.status, 500);
t.equal(normalSearch.status, 200);
t.match(stdout, /Starting agent/);
t.match(stderr, /Zen has blocked an SQL injection/);
}
)
.catch((error) => {
t.fail(error.message);
})
.finally(() => {
server.kill();
}),
]);
})
.then(
async ([sqlInjection, sqlInjection2, sqlInjection3, normalSearch]) => {
t.equal(sqlInjection.status, 500);
t.equal(sqlInjection2.status, 500);
t.equal(sqlInjection3.status, 500);
t.equal(normalSearch.status, 200);
t.match(stdout, /Starting agent/);
t.match(stderr, /Zen has blocked an SQL injection/);
}
)
.catch((error) => {
t.fail(error);
})
.finally(() => {
server.kill();
});
});

t.test(`it does not block in dry mode (${entrypoint})`, (t) => {
const server = spawn(`node`, ["--preserve-symlinks", entrypoint, "4001"], {
env: { ...process.env, AIKIDO_DEBUG: "true" },
cwd: directory,
});
});

t.test("it does not block in dry mode", (t) => {
const server = spawn(`node`, ["--preserve-symlinks", pathToApp, "4001"], {
env: { ...process.env, AIKIDO_DEBUG: "true" },
});
server.on("close", () => {
t.end();
});

server.on("close", () => {
t.end();
});
server.on("error", (err) => {
t.fail(err);
});

let stdout = "";
server.stdout.on("data", (data) => {
stdout += data.toString();
});
let stdout = "";
server.stdout.on("data", (data) => {
stdout += data.toString();
});

let stderr = "";
server.stderr.on("data", (data) => {
stderr += data.toString();
});
let stderr = "";
server.stderr.on("data", (data) => {
stderr += data.toString();
});

// Wait for the server to start
timeout(2000)
.then(() =>
Promise.all([
fetch(
`http://localhost:4001/?petname=${encodeURIComponent("Njuska'); DELETE FROM cats_2;-- H")}`,
{
// Wait for the server to start
timeout(2000)
.then(() =>
Promise.all([
fetch(
`http://localhost:4001/?petname=${encodeURIComponent("Njuska'); DELETE FROM cats_2;-- H")}`,
{
signal: AbortSignal.timeout(5000),
}
),
fetch(`http://localhost:4001/string-concat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ petname: ["'", "1)", "(0,1)", "(1", "'"] }),
signal: AbortSignal.timeout(5000),
}
),
fetch(`http://localhost:4001/string-concat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ petname: ["'", "1)", "(0,1)", "(1", "'"] }),
signal: AbortSignal.timeout(5000),
}),
fetch(
`http://localhost:4001/string-concat?petname='&petname=1)&petname=(0,1)&petname=(1&petname='`,
{
}),
fetch(
`http://localhost:4001/string-concat?petname='&petname=1)&petname=(0,1)&petname=(1&petname='`,
{
signal: AbortSignal.timeout(5000),
}
),
fetch("http://localhost:4001/?petname=Njuska", {
signal: AbortSignal.timeout(5000),
}
),
fetch("http://localhost:4001/?petname=Njuska", {
signal: AbortSignal.timeout(5000),
}),
])
)
.then(
async ([sqlInjection, sqlInjection2, sqlInjection3, normalSearch]) => {
t.equal(sqlInjection.status, 200);
t.equal(sqlInjection2.status, 200);
t.equal(sqlInjection3.status, 200);
t.equal(normalSearch.status, 200);
t.match(stdout, /Starting agent/);
t.notMatch(stderr, /Zen has blocked an SQL injection/);
}
)
.catch((error) => {
t.fail(error.message);
})
.finally(() => {
server.kill();
});
}),
])
)
.then(
async ([sqlInjection, sqlInjection2, sqlInjection3, normalSearch]) => {
t.equal(sqlInjection.status, 200);
t.equal(sqlInjection2.status, 200);
t.equal(sqlInjection3.status, 200);
t.equal(normalSearch.status, 200);
t.match(stdout, /Starting agent/);
t.notMatch(stderr, /Zen has blocked an SQL injection/);
}
)
.catch((error) => {
t.fail(error);
})
.finally(() => {
server.kill();
});
});
});

t.test("it blocks in blocking mode (with dd-trace)", (t) => {
const server = spawn(
`node`,
["--preserve-symlinks", "--require", "dd-trace/init", pathToApp, "4002"],
["--preserve-symlinks", "--require", "dd-trace/init", "app.js", "4002"],
{
env: { ...process.env, AIKIDO_DEBUG: "true", AIKIDO_BLOCKING: "true" },
cwd: resolve(__dirname, "../../sample-apps/express-postgres"),
cwd: directory,
}
);

Expand Down
2 changes: 1 addition & 1 deletion library/agent/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function getAgent({ serverless }: { serverless: string | undefined }) {
return agent;
}

function getWrappers() {
export function getWrappers() {
return [
new Express(),
new MongoDB(),
Expand Down
8 changes: 8 additions & 0 deletions library/bundler/externals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as t from "tap";
import { externals } from "./externals";

t.test("it returns externals", async (t) => {
t.ok(externals().includes("@aikidosec/firewall"));
t.ok(externals().includes("pg"));
t.ok(externals().includes("mysql"));
});
18 changes: 18 additions & 0 deletions library/bundler/externals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Hooks } from "../agent/hooks/Hooks";
import { getWrappers } from "../agent/protect";

export function externals() {
const wrappers = getWrappers();
const hooks = new Hooks();

wrappers.forEach((wrapper) => {
wrapper.wrap(hooks);
});

const packages = ["@aikidosec/firewall"].concat(
hooks.getPackages().map((pkg) => pkg.getName())
);

// Remove duplicates
return Array.from(new Set(packages));
}
4 changes: 4 additions & 0 deletions library/bundler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { externals } from "./externals";

// eslint-disable-next-line import/no-unused-modules
export { externals };
1 change: 1 addition & 0 deletions sample-apps/express-postgres/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/compiled.js
11 changes: 11 additions & 0 deletions sample-apps/express-postgres/esbuild.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const { build } = require("esbuild");
const { externals } = require("@aikidosec/firewall/bundler");

build({
entryPoints: ["./app.js"],
bundle: true,
platform: "node",
target: "node18",
outfile: "./compiled.js",
external: externals(),
});
Loading
Loading