Skip to content

Commit

Permalink
Merge branch 'main' into clean-stacktraces
Browse files Browse the repository at this point in the history
  • Loading branch information
timokoessler committed Dec 19, 2024
2 parents a3f84fd + 8871c7d commit 869b773
Show file tree
Hide file tree
Showing 34 changed files with 1,475 additions and 107 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/lint-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: make install
- run: make install-lib-only
- run: make build
- run: make lint
12 changes: 11 additions & 1 deletion .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ jobs:
"CLICKHOUSE_DEFAULT_ACCESS": "MANAGEMENT=1"
ports:
- "27019:8123"
mongodb-replica:
image: bitnami/mongodb:8.0
env:
MONGODB_ADVERTISED_HOSTNAME: 127.0.0.1
MONGODB_REPLICA_SET_MODE: primary
MONGODB_ROOT_USER: root
MONGODB_ROOT_PASSWORD: password
MONGODB_REPLICA_SET_KEY: replicasetkey123
ports:
- "27020:27017"
strategy:
fail-fast: false
matrix:
Expand All @@ -64,7 +74,7 @@ jobs:
- name: Add local.aikido.io to /etc/hosts
run: |
sudo echo "127.0.0.1 local.aikido.io" | sudo tee -a /etc/hosts
- run: make install
- run: make install-lib-only
- run: make build
- run: make test-ci
- name: "Upload coverage"
Expand Down
11 changes: 9 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,21 @@ koa-sqlite3:
fastify-clickhouse:
cd sample-apps/fastify-clickhouse && AIKIDO_DEBUG=true AIKIDO_BLOCKING=true node app.js

.PHONY: install
install:
.PHONY: hono-prisma
hono-prisma:
cd sample-apps/hono-prisma && AIKIDO_DEBUG=true AIKIDO_BLOCK=true node app.js

.PHONY: install-lib-only
install-lib-only:
mkdir -p build
node scripts/copyPackageJSON.js
touch build/index.js
cd build && npm link
npm install
cd library && npm install

.PHONY: install
install: install-lib-only
cd end2end && npm install
node scripts/install.js

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Zen for Node.js 16+ is compatible with:
*[`better-sqlite3`](https://www.npmjs.com/package/better-sqlite3) 11.x, 10.x, 9.x and 8.x
*[`postgres`](https://www.npmjs.com/package/postgres) 3.x
*[`@clickhouse/client`](https://www.npmjs.com/package/@clickhouse/client) 1.x
*[`@prisma/client`](https://www.npmjs.com/package/@prisma/client) 5.x

### Cloud providers

Expand Down
129 changes: 129 additions & 0 deletions end2end/tests/hono-prisma.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
const t = require("tap");
const { spawn } = require("child_process");
const { resolve, join } = require("path");
const timeout = require("../timeout");
const { promisify } = require("util");
const { exec: execCb } = require("child_process");

const execAsync = promisify(execCb);

const appDir = resolve(__dirname, "../../sample-apps/hono-prisma");
const pathToApp = join(appDir, "app.js");

process.env.DATABASE_URL = "file:./dev.db";

t.before(async (t) => {
// Generate prismajs client
const { stdout, stderr } = await execAsync(
"npx prisma migrate reset --force", // Generate prisma client, reset db and apply migrations
{
cwd: appDir,
}
);

if (stderr) {
t.fail(stderr);
}
});

t.test("it blocks in blocking mode", (t) => {
const server = spawn(`node`, [pathToApp, "4002"], {
env: { ...process.env, AIKIDO_DEBUG: "true", AIKIDO_BLOCKING: "true" },
});

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

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://127.0.0.1:4002/posts/Test" OR 1=1 -- C', {
method: "GET",
signal: AbortSignal.timeout(5000),
}),
fetch("http://127.0.0.1:4002/posts/Happy", {
method: "GET",
signal: AbortSignal.timeout(5000),
}),
]);
})
.then(([sqlInjection, normalAdd]) => {
t.equal(sqlInjection.status, 500);
t.equal(normalAdd.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 non-blocking mode", (t) => {
const server = spawn(`node`, [pathToApp, "4002"], {
env: { ...process.env, AIKIDO_DEBUG: "true" },
});

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

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://127.0.0.1:4002/posts/Test" OR 1=1 -- C', {
method: "GET",
signal: AbortSignal.timeout(5000),
}),
fetch("http://127.0.0.1:4002/posts/Happy", {
method: "GET",
signal: AbortSignal.timeout(5000),
}),
]);
})
.then(([sqlInjection, normalAdd]) => {
t.equal(sqlInjection.status, 200);
t.equal(normalAdd.status, 200);
t.match(stdout, /Starting agent/);
t.notMatch(stderr, /Zen has blocked an SQL injection/);
})
.catch((error) => {
t.fail(error);
})
.finally(() => {
server.kill();
});
});
57 changes: 57 additions & 0 deletions library/agent/hooks/onInspectionInterceptorResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { resolve } from "path";
import { cleanupStackTrace } from "../../helpers/cleanupStackTrace";
import { escapeHTML } from "../../helpers/escapeHTML";
import type { Agent } from "../Agent";
import { attackKindHumanName } from "../Attack";
import { getContext, updateContext } from "../Context";
import type { InterceptorResult } from "./InterceptorResult";
import type { WrapPackageInfo } from "./WrapPackageInfo";

// Used for cleaning up the stack trace
const libraryRoot = resolve(__dirname, "../..");

export function onInspectionInterceptorResult(
context: ReturnType<typeof getContext>,
agent: Agent,
result: InterceptorResult,
pkgInfo: WrapPackageInfo,
start: number
) {
const end = performance.now();
agent.getInspectionStatistics().onInspectedCall({
sink: pkgInfo.name,
attackDetected: !!result,
blocked: agent.shouldBlock(),
durationInMs: end - start,
withoutContext: !context,
});

const isAllowedIP =
context &&
context.remoteAddress &&
agent.getConfig().isAllowedIP(context.remoteAddress);

if (result && context && !isAllowedIP) {
// Flag request as having an attack detected
updateContext(context, "attackDetected", true);

agent.onDetectedAttack({
module: pkgInfo.name,
operation: result.operation,
kind: result.kind,
source: result.source,
blocked: agent.shouldBlock(),
stack: cleanupStackTrace(new Error().stack!, libraryRoot),
paths: result.pathsToPayload,
metadata: result.metadata,
request: context,
payload: result.payload,
});

if (agent.shouldBlock()) {
throw new Error(
`Zen has blocked ${attackKindHumanName(result.kind)}: ${result.operation}(...) originating from ${result.source}${escapeHTML((result.pathsToPayload || []).join())}`
);
}
}
}
53 changes: 5 additions & 48 deletions library/agent/hooks/wrapExport.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
/* eslint-disable max-lines-per-function */
import { cleanupStackTrace } from "../../helpers/cleanupStackTrace";
import { escapeHTML } from "../../helpers/escapeHTML";
import { Agent } from "../Agent";
import { getInstance } from "../AgentSingleton";
import { attackKindHumanName } from "../Attack";
import { bindContext, getContext, updateContext } from "../Context";
import { InterceptorResult } from "./InterceptorResult";
import { WrapPackageInfo } from "./WrapPackageInfo";
import { bindContext, getContext } from "../Context";
import type { InterceptorResult } from "./InterceptorResult";
import type { WrapPackageInfo } from "./WrapPackageInfo";
import { wrapDefaultOrNamed } from "./wrapDefaultOrNamed";
import { getLibraryRoot } from "../../helpers/getLibraryRoot";
import { cleanError } from "../../helpers/cleanError";
import { onInspectionInterceptorResult } from "./onInspectionInterceptorResult";

type InspectArgsInterceptor = (
args: unknown[],
Expand Down Expand Up @@ -152,44 +148,5 @@ function inspectArgs(
module: pkgInfo.name,
});
}

const end = performance.now();
agent.getInspectionStatistics().onInspectedCall({
sink: pkgInfo.name,
attackDetected: !!result,
blocked: agent.shouldBlock(),
durationInMs: end - start,
withoutContext: !context,
});

const isAllowedIP =
context &&
context.remoteAddress &&
agent.getConfig().isAllowedIP(context.remoteAddress);

if (result && context && !isAllowedIP) {
// Flag request as having an attack detected
updateContext(context, "attackDetected", true);

agent.onDetectedAttack({
module: pkgInfo.name,
operation: result.operation,
kind: result.kind,
source: result.source,
blocked: agent.shouldBlock(),
stack: cleanupStackTrace(new Error().stack!, getLibraryRoot()),
paths: result.pathsToPayload,
metadata: result.metadata,
request: context,
payload: result.payload,
});

if (agent.shouldBlock()) {
throw cleanError(
new Error(
`Zen has blocked ${attackKindHumanName(result.kind)}: ${result.operation}(...) originating from ${result.source}${escapeHTML((result.pathsToPayload || []).join())}`
)
);
}
}
onInspectionInterceptorResult(context, agent, result, pkgInfo, start);
}
70 changes: 70 additions & 0 deletions library/agent/hooks/wrapNewInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,73 @@ t.test("Can wrap default export", async (t) => {
// @ts-expect-error Test method is added by interceptor
t.same(instance.testMethod(), "aikido");
});

t.test("Errors in interceptor are caught", async (t) => {
const exports = {
test: class Test {
constructor(private input: string) {}

getInput() {
return this.input;
}
},
};

logger.clear();

wrapNewInstance(exports, "test", { name: "test", type: "external" }, () => {
throw new Error("test error");
});

const instance = new exports.test("input");
t.same(instance.getInput(), "input");
t.same(logger.getMessages(), ["Failed to wrap method test in module test"]);
});

t.test("Return value from interceptor is returned", async (t) => {
const exports = {
test: class Test {
constructor(private input: string) {}

getInput() {
return this.input;
}
},
};

wrapNewInstance(exports, "test", { name: "test", type: "external" }, () => {
return { testMethod: () => "aikido" };
});

const instance = new exports.test("input");
t.same(typeof instance.getInput, "undefined");
// @ts-expect-error Test method is added by interceptor
t.same(instance.testMethod(), "aikido");
});

t.test("Logs error when wrapping default export", async (t) => {
let exports = class Test {
constructor(private input: string) {}

getInput() {
return this.input;
}
};

logger.clear();

exports = wrapNewInstance(
exports,
undefined,
{ name: "test", type: "external" },
() => {
throw new Error("test error");
}
) as any;

const instance = new exports("input");
t.same(instance.getInput(), "input");
t.same(logger.getMessages(), [
"Failed to wrap method default export in module test",
]);
});
Loading

0 comments on commit 869b773

Please sign in to comment.