Skip to content

Commit 72adc28

Browse files
committed
Add prepare() and setToken() for runtime token setup
Users who store their token in a secrets manager (e.g. AWS Secrets Manager) can't set AIKIDO_TOKEN before the module loads. prepare() starts instrumentation without a token, and setToken() connects to the platform once the token is fetched async. Teams shouldn't have to modify their whole app structure just to adopt Zen.
1 parent e063d1a commit 72adc28

11 files changed

Lines changed: 581 additions & 4 deletions

File tree

docs/set-token.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Setting the token at runtime
2+
3+
Zen normally reads the token from the `AIKIDO_TOKEN` environment variable. If you can't set env vars — for example, your token lives in AWS Secrets Manager — you can set it at runtime instead.
4+
5+
## How it works
6+
7+
1. Call `prepare()` at startup. This starts Zen's instrumentation without a token.
8+
2. Fetch your token async (secrets manager, config service, wherever).
9+
3. Call `setToken(token)` to connect to the Aikido platform.
10+
11+
Zen detects attacks from step 1, but won't report them until you call `setToken`.
12+
13+
## Example with AWS Secrets Manager
14+
15+
```js
16+
const Zen = require("@aikidosec/firewall");
17+
18+
// Start instrumentation without a token
19+
Zen.prepare();
20+
21+
const {
22+
SecretsManagerClient,
23+
GetSecretValueCommand,
24+
} = require("@aws-sdk/client-secrets-manager");
25+
26+
async function loadToken() {
27+
const client = new SecretsManagerClient();
28+
const response = await client.send(
29+
new GetSecretValueCommand({ SecretId: "my-secret" })
30+
);
31+
return response.SecretString;
32+
}
33+
34+
loadToken().then((token) => {
35+
Zen.setToken(token);
36+
});
37+
```
38+
39+
## With ESM
40+
41+
Create a setup file for ESM:
42+
43+
```js
44+
// zen-setup.cjs
45+
const { prepare } = require("@aikidosec/firewall/instrument");
46+
47+
prepare();
48+
```
49+
50+
Start your app with:
51+
52+
```sh
53+
node -r ./zen-setup.cjs app.js
54+
```
55+
56+
Then call `setToken` in your application code:
57+
58+
```js
59+
import { setToken } from "@aikidosec/firewall";
60+
61+
const token = await fetchTokenFromSecretsManager();
62+
setToken(token);
63+
```
64+
65+
## With Lambda
66+
67+
Call `prepare()` before wrapping your handler:
68+
69+
```js
70+
const Zen = require("@aikidosec/firewall");
71+
Zen.prepare();
72+
73+
const zen = require("@aikidosec/firewall/lambda");
74+
75+
module.exports.handler = zen(async (event) => {
76+
// Your handler code
77+
});
78+
79+
// Fetch token outside the handler so it runs once during cold start
80+
loadToken().then((token) => {
81+
Zen.setToken(token);
82+
});
83+
```
84+
85+
## Notes
86+
87+
- Call `prepare()` as early as possible, before other packages are loaded.
88+
- `setToken` only works once. Calling it again is ignored.
89+
- If `AIKIDO_TOKEN` is already set in the environment, you don't need `prepare()` or `setToken()`. Calling them anyway is fine — they just do nothing.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { spawn } from "child_process";
2+
import { resolve } from "path";
3+
import { test } from "node:test";
4+
import { equal, fail, ok } from "node:assert";
5+
import { getRandomPort } from "./utils/get-port.mjs";
6+
import { timeout } from "./utils/timeout.mjs";
7+
8+
const pathToAppDir = resolve(
9+
import.meta.dirname,
10+
"../../sample-apps/hono-pg-esm"
11+
);
12+
13+
const testServerUrl = "http://localhost:5874";
14+
15+
test(
16+
"it blocks after setToken is called and sends a heartbeat (ESM)",
17+
{ timeout: 60000 },
18+
async () => {
19+
const port = await getRandomPort();
20+
21+
const response = await fetch(`${testServerUrl}/api/runtime/apps`, {
22+
method: "POST",
23+
});
24+
const body = await response.json();
25+
const token = body.token;
26+
27+
const server = spawn(
28+
`node`,
29+
["--require", "./zen-setup.cjs", "./app-set-token.js", port],
30+
{
31+
cwd: pathToAppDir,
32+
env: {
33+
...process.env,
34+
AIKIDO_TOKEN: token,
35+
AIKIDO_ENDPOINT: testServerUrl,
36+
AIKIDO_REALTIME_ENDPOINT: testServerUrl,
37+
AIKIDO_DEBUG: "true",
38+
AIKIDO_BLOCK: "true",
39+
},
40+
}
41+
);
42+
43+
try {
44+
server.on("error", (err) => {
45+
fail(err);
46+
});
47+
48+
let stdout = "";
49+
server.stdout.on("data", (data) => {
50+
stdout += data.toString();
51+
});
52+
53+
let stderr = "";
54+
server.stderr.on("data", (data) => {
55+
stderr += data.toString();
56+
});
57+
58+
// Wait for server + setToken (500ms delay in app)
59+
await timeout(2000);
60+
61+
const [sqlInjection, normalAdd] = await Promise.all([
62+
fetch(`http://127.0.0.1:${port}/add`, {
63+
method: "POST",
64+
body: JSON.stringify({
65+
name: "Njuska'); DELETE FROM cats_6;-- H",
66+
}),
67+
headers: { "Content-Type": "application/json" },
68+
signal: AbortSignal.timeout(5000),
69+
}),
70+
fetch(`http://127.0.0.1:${port}/add`, {
71+
method: "POST",
72+
body: JSON.stringify({ name: "Miau" }),
73+
headers: { "Content-Type": "application/json" },
74+
signal: AbortSignal.timeout(5000),
75+
}),
76+
]);
77+
78+
equal(sqlInjection.status, 500);
79+
equal(normalAdd.status, 200);
80+
ok(stdout.includes("Starting agent"), "should log starting agent");
81+
ok(
82+
stderr.includes("Zen has blocked an SQL injection"),
83+
"should log blocked SQL injection"
84+
);
85+
86+
// Wait for heartbeat (agent sends after ~30s)
87+
await timeout(31000);
88+
89+
const eventsResponse = await fetch(
90+
`${testServerUrl}/api/runtime/events`,
91+
{
92+
method: "GET",
93+
headers: { Authorization: token },
94+
signal: AbortSignal.timeout(5000),
95+
}
96+
);
97+
98+
const events = await eventsResponse.json();
99+
const startedEvents = events.filter((e) => e.type === "started");
100+
equal(startedEvents.length, 1, "should have 1 started event");
101+
102+
const heartbeatEvents = events.filter((e) => e.type === "heartbeat");
103+
equal(heartbeatEvents.length, 1, "should have 1 heartbeat event");
104+
} catch (err) {
105+
fail(err);
106+
} finally {
107+
server.kill();
108+
}
109+
}
110+
);
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
const t = require("tap");
2+
const { spawn } = require("child_process");
3+
const { resolve } = require("path");
4+
const timeout = require("../timeout");
5+
6+
const pathToApp = resolve(
7+
__dirname,
8+
"../../sample-apps/express-mysql",
9+
"app-set-token.js"
10+
);
11+
12+
const testServerUrl = "http://localhost:5874";
13+
14+
let token;
15+
t.beforeEach(async () => {
16+
const response = await fetch(`${testServerUrl}/api/runtime/apps`, {
17+
method: "POST",
18+
});
19+
const body = await response.json();
20+
token = body.token;
21+
});
22+
23+
t.test(
24+
"it blocks after setToken is called and sends a heartbeat",
25+
{ timeout: 60000 },
26+
(t) => {
27+
const server = spawn(`node`, [pathToApp, "4020"], {
28+
env: {
29+
...process.env,
30+
AIKIDO_TOKEN: token,
31+
AIKIDO_ENDPOINT: testServerUrl,
32+
AIKIDO_REALTIME_ENDPOINT: testServerUrl,
33+
AIKIDO_DEBUG: "true",
34+
AIKIDO_BLOCKING: "true",
35+
},
36+
});
37+
38+
server.on("close", () => {
39+
t.end();
40+
});
41+
42+
server.on("error", (err) => {
43+
t.fail(err.message);
44+
});
45+
46+
let stdout = "";
47+
server.stdout.on("data", (data) => {
48+
stdout += data.toString();
49+
});
50+
51+
let stderr = "";
52+
server.stderr.on("data", (data) => {
53+
stderr += data.toString();
54+
});
55+
56+
// Wait for server + setToken (500ms delay in app)
57+
timeout(2000)
58+
.then(() => {
59+
return Promise.all([
60+
fetch(
61+
`http://localhost:4020/?petname=${encodeURIComponent("Njuska'); DELETE FROM cats;-- H")}`,
62+
{
63+
signal: AbortSignal.timeout(5000),
64+
}
65+
),
66+
fetch("http://localhost:4020/?petname=Njuska", {
67+
signal: AbortSignal.timeout(5000),
68+
}),
69+
]);
70+
})
71+
.then(([sqlInjection, normalSearch]) => {
72+
t.equal(sqlInjection.status, 500);
73+
t.equal(normalSearch.status, 200);
74+
t.match(stdout, /Starting agent/);
75+
t.match(stderr, /Zen has blocked an SQL injection/);
76+
})
77+
.then(() => {
78+
// Wait for heartbeat (agent sends after ~30s)
79+
return timeout(31000);
80+
})
81+
.then(() => {
82+
return fetch(`${testServerUrl}/api/runtime/events`, {
83+
method: "GET",
84+
headers: {
85+
Authorization: token,
86+
},
87+
signal: AbortSignal.timeout(5000),
88+
});
89+
})
90+
.then((response) => response.json())
91+
.then((events) => {
92+
const startedEvents = events.filter((e) => e.type === "started");
93+
t.equal(startedEvents.length, 1);
94+
95+
const heartbeatEvents = events.filter((e) => e.type === "heartbeat");
96+
t.equal(heartbeatEvents.length, 1);
97+
})
98+
.catch((error) => {
99+
t.fail(error.message);
100+
})
101+
.finally(() => {
102+
server.kill();
103+
});
104+
}
105+
);

library/agent/Agent.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export class Agent {
7272
private block: boolean,
7373
private readonly logger: Logger,
7474
private readonly api: ReportingAPI,
75-
private readonly token: Token | undefined,
75+
private token: Token | undefined,
7676
private readonly serverless: string | undefined,
7777
private readonly newInstrumentation: boolean = false,
7878
private readonly fetchListsAPI: FetchListsAPI
@@ -555,6 +555,24 @@ export class Agent {
555555
});
556556
}
557557

558+
hasToken() {
559+
return this.token !== undefined;
560+
}
561+
562+
setToken(token: Token) {
563+
this.token = token;
564+
this.logger.log("Token set, enabling reporting.");
565+
566+
this.onStart()
567+
.then(() => {
568+
this.startHeartbeats();
569+
this.startPollingForConfigChanges();
570+
})
571+
.catch((err) => {
572+
console.error(`Aikido: Failed to start agent: ${err.message}`);
573+
});
574+
}
575+
558576
onFailedToWrapMethod(module: string, name: string, error: Error) {
559577
this.logger.log(
560578
`Failed to wrap method ${name} in module ${module}: ${error.message}`

0 commit comments

Comments
 (0)