Skip to content
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,9 @@ A run takes `RunConfig` (`src/service.ts`): `tenant`, `mode`, `rules`,
goes to a model in a prompt. The service keeps them only in memory, for
the run. A run can have 32 of them. It also learns the value of each request header whose name can mean a credential,
such as one with `auth`, `token`, `key`, `secret`, `cookie`, or `session`.
The match is broad on purpose. Recordings show `<<redacted>>` in place of
each known value.
The match is broad on purpose. It does not learn `x-session-affinity`,
because that header has a conversation id and not a credential.
Recordings show `<<redacted>>` in place of each known value.

Requests that match no rule go live without a change, and their responses
stream. Each response has an `x-roach` header: `replayed`, `live`,
Expand Down
9 changes: 9 additions & 0 deletions src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import type { IncomingHttpHeaders } from "node:http";
*/
const CREDENTIAL_HEADER =
/auth|token|secret|key|cookie|session|password|credential|signature/i;
/**
* Headers that match `CREDENTIAL_HEADER` but carry no credential. A response
* can show their values, and the client must get them back without a change.
* AI Gateway clients put a conversation id in `x-session-affinity`, and a
* model can repeat that id in its response. Node gives header names in
* lower case.
*/
const NOT_CREDENTIAL_HEADERS = new Set(["x-session-affinity"]);
/** Shorter parts, such as `Bearer`, are not credentials. */
const MIN_LENGTH = 12;
const REDACTED = "<<redacted>>";
Expand Down Expand Up @@ -41,6 +49,7 @@ export function createSecrets(initial: string[] = []) {
learn(headers: IncomingHttpHeaders): void {
for (const [name, value] of Object.entries(headers)) {
if (!CREDENTIAL_HEADER.test(name) || value === undefined) continue;
if (NOT_CREDENTIAL_HEADERS.has(name)) continue;
for (const item of Array.isArray(value) ? value : [value]) add(item);
}
},
Expand Down
28 changes: 28 additions & 0 deletions tests/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ beforeEach(async () => {
n: liveRequests,
key: incoming.headers["x-api-key"] ?? null,
echo: incoming.headers["x-echo"] ?? null,
affinity: incoming.headers["x-session-affinity"] ?? null,
serviceSecret: "service-secret-value-123",
}),
);
Expand Down Expand Up @@ -386,6 +387,7 @@ describe("roach service", () => {
n: 1,
key: "<<redacted>>",
echo: "<<redacted>>",
affinity: null,
serviceSecret: "<<redacted>>",
});
const [file] = await recordings("acme/alpha");
Expand All @@ -397,4 +399,30 @@ describe("roach service", () => {
expect(text).not.toContain("service-secret-value-123");
expect(text).not.toContain("run-secret-value-789");
});

it("does not redact the session affinity header", async () => {
// AI Gateway clients send a conversation id in `x-session-affinity`.
// It is not a credential, and the client must get it back as it is.
const run = await startRun("write-token", "auto");
const response = await send(
run.url,
{ prompt: "hi" },
{
"x-api-key": "sk-live-request-key-456",
"x-session-affinity": "conversation-id-0123456789",
},
);

expect(JSON.parse(response.body)).toMatchObject({
key: "<<redacted>>",
affinity: "conversation-id-0123456789",
});
const [file] = await recordings("acme/alpha");
const text = await readFile(
path.join(directory, "acme/alpha", "model", file!),
"utf8",
);
expect(text).toContain("conversation-id-0123456789");
expect(text).not.toContain("sk-live-request-key-456");
});
});
Loading