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
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"src/lib/messaging/channels/index.ts": 25,
"src/lib/onboard/gateway-binding.ts": 52,
"src/lib/runner.ts": 87,
"src/lib/security/redact.ts": 53,
"src/lib/security/redact.ts": 54,
"src/lib/state/onboard-session.ts": 37,
"src/lib/state/registry.ts": 101,
"src/lib/state/state-root.ts": 21,
Expand Down
71 changes: 63 additions & 8 deletions src/lib/actions/sandbox/gateway-wedge-diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,82 @@ describe("collectGatewayWedgeDiagnostics wedge signature (#4710)", () => {
stdout:
"gateway startup failed: Authorization: Bearer abc.def.ghi rejected\n" +
'gateway startup failed: api_key="nv-secret-123" invalid\n' +
"gateway startup failed: \u001b[31mboom\u001b[0m Process will stay alive\n",
"gateway startup failed: \u001b[31mboom\u001b[0m Process will stay alive\n" +
"gateway startup failed: env OPENAI_API_KEY=example-not-a-real-value-0001\n" +
"gateway startup failed: SLACK_BOT_TOKEN=example-not-a-real-value-0002\n" +
"Process will stay alive; TELEGRAM_BOT_TOKEN=example-not-a-real-value-0003\n",
stderr: "",
}));
expect(lines[0]).toBe("gateway startup failed: Authorization: Bearer [REDACTED] rejected");
expect(lines[1]).toBe('gateway startup failed: api_key="[REDACTED] invalid');
expect(lines[1]).toBe('gateway startup failed: api_key="<REDACTED>" invalid');
// Terminal escape sequences are stripped so sandbox output cannot forge
// operator-terminal content.
expect(lines[2]).toBe("gateway startup failed: [31mboom[0m Process will stay alive");
expect(lines[2]).not.toContain("\u001b");
// Underscore-separated credential env names need the shared redactor: `\b`
// does not match between `_` and a word character, so the local patterns
// above never reach the assignment.
expect(lines[3]).toBe("gateway startup failed: env OPENAI_API_KEY=<REDACTED>");
expect(lines[4]).toBe("gateway startup failed: SLACK_BOT_TOKEN=<REDACTED>");
expect(lines[5]).toBe("Process will stay alive; TELEGRAM_BOT_TOKEN=<REDACTED>");
});
});

describe("sanitizeWedgeLogLine", () => {
it("redacts nvapi keys and token assignments", () => {
expect(sanitizeWedgeLogLine("auth with nvapi-AbC123xyz failed")).toBe(
"auth with [REDACTED] failed",
);
expect(sanitizeWedgeLogLine("retry token=sk-live-456 now")).toBe("retry token=[REDACTED] now");
it("keeps the wedge-specific short nvapi fallback", () => {
expect(sanitizeWedgeLogLine("auth with nvapi-x failed")).toBe("auth with [REDACTED] failed");
});

it("uses the shared redactor for generic credential assignments", () => {
expect(sanitizeWedgeLogLine("retry token=sk-live-456 now")).toBe("retry token=<REDACTED> now");
expect(sanitizeWedgeLogLine("PASSWORD: hunter2 rejected")).toBe(
"PASSWORD: [REDACTED] rejected",
"PASSWORD: <REDACTED> rejected",
);
});

it.each([
['OPENAI_API_KEY="opaque api key value"', "opaque api key value"],
["SERVICE_TOKEN='opaque token value'", "opaque token value"],
['DATABASE_PASSWORD="opaque password value"', "opaque password value"],
["WEBHOOK_SECRET='opaque secret value'", "opaque secret value"],
])("redacts the complete quoted value in %s", (assignment, secret) => {
const sanitized = sanitizeWedgeLogLine(
`gateway startup failed: ${assignment}; safe diagnostic remains`,
);

expect(sanitized).not.toContain(secret);
expect(sanitized).toContain("REDACTED");
expect(sanitized).toContain("; safe diagnostic remains");
});

it.each([
["double-quoted", 'api_key="opaque first second', 'api_key="<REDACTED>"'],
["single-quoted", "token='opaque first second", "token='<REDACTED>'"],
[
"double-quoted with a dangling backslash",
'api_key="opaque first second\\',
'api_key="<REDACTED>"',
],
[
"single-quoted with a dangling backslash",
"token='opaque first second\\",
"token='<REDACTED>'",
],
])(
"fails closed for an unterminated %s wedge-log secret assignment (#9863)",
(_case, assignment, expected) => {
const sanitized = sanitizeWedgeLogLine(`gateway startup failed: ${assignment}`);

expect(sanitized).toBe(`gateway startup failed: ${expected}`);
},
);

it("preserves a carriage-return boundary until redaction completes", () => {
expect(
sanitizeWedgeLogLine(
'gateway startup failed: CUSTOM_TOKEN="opaque secret\rsafe diagnostic"',
),
).toBe('gateway startup failed: CUSTOM_TOKEN="<REDACTED>"safe diagnostic"');
});

it("leaves ordinary wedge lines untouched", () => {
Expand Down
20 changes: 10 additions & 10 deletions src/lib/actions/sandbox/gateway-wedge-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
// defaulted off.

import { shellQuote } from "../../runner";
import { redactFull } from "../../security/redact";
import type { SandboxCommandResult } from "./process-recovery";

export type SandboxExec = (sandboxName: string, command: string) => SandboxCommandResult | null;
Expand All @@ -29,18 +30,17 @@ const WEDGE_LOG_SIGNATURE =
// strip terminal control characters (no escape-sequence forgery in operator
// terminals) and redact common credential shapes before printing.
const CONTROL_CHARS_RE = new RegExp("[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f]", "g");
const SECRET_PATTERNS: RegExp[] = [
/\b(authorization\s*:\s*bearer)\s+\S+/gi,
/\b(api[-_]?key|token|secret|password)(["']?\s*[=:]\s*["']?)\S+/gi,
/\bnvapi-\S+/gi,
];
// These wedge-specific patterns run after redactFull. The `nvapi-\S+`
// catch-all is unbounded where the shared one requires ten or more characters.
const AUTHORIZATION_PATTERN = /\b(authorization\s*:\s*bearer)\s+\S+/gi;
const NVAPI_PATTERN = /\bnvapi-\S+/gi;

export function sanitizeWedgeLogLine(line: string): string {
let sanitized = line.replace(CONTROL_CHARS_RE, "");
sanitized = sanitized.replace(SECRET_PATTERNS[0], "$1 [REDACTED]");
sanitized = sanitized.replace(SECRET_PATTERNS[1], "$1$2[REDACTED]");
sanitized = sanitized.replace(SECRET_PATTERNS[2], "[REDACTED]");
return sanitized.trim();
let sanitized = line.replace(CONTROL_CHARS_RE, (char) => (char === "\r" ? char : ""));
sanitized = redactFull(sanitized);
sanitized = sanitized.replace(AUTHORIZATION_PATTERN, "$1 [REDACTED]");
sanitized = sanitized.replace(NVAPI_PATTERN, "[REDACTED]");
return sanitized.replace(/\r/g, "").trim();
}

/**
Expand Down
81 changes: 81 additions & 0 deletions src/lib/security/redact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,87 @@ describe("redactForLog", () => {
expect(redactForLog(assignment)).toBe(expected);
});

it.each([
[
'CUSTOM_API_KEY="opaque api key value" safe diagnostic',
'CUSTOM_API_KEY="<REDACTED>" safe diagnostic',
],
[
"CUSTOM_TOKEN='opaque token value' safe diagnostic",
"CUSTOM_TOKEN='<REDACTED>' safe diagnostic",
],
[
'CUSTOM_PASSWORD="opaque password value" safe diagnostic',
'CUSTOM_PASSWORD="<REDACTED>" safe diagnostic',
],
[
"CUSTOM_SECRET='opaque secret value' safe diagnostic",
"CUSTOM_SECRET='<REDACTED>' safe diagnostic",
],
])("redacts the complete quoted assignment in %s", (assignment, expected) => {
expect(redactFull(assignment)).toBe(expected);
expect(redactForLog(assignment)).toBe(expected);
});

it("redacts a quoted multiword sensitive environment assignment before its suffix", () => {
expect(redactSensitiveText('OPENAI_API_KEY="opaque first second" safe diagnostic')).toBe(
"OPENAI_API_KEY=<REDACTED> safe diagnostic",
);
});

it.each([
[
"double-quoted",
'OPENAI_API_KEY="opaque first second\nsafe diagnostic',
'OPENAI_API_KEY="<REDACTED>"\nsafe diagnostic',
],
[
"single-quoted",
"OPENAI_API_KEY='opaque first second\nsafe diagnostic",
"OPENAI_API_KEY='<REDACTED>'\nsafe diagnostic",
],
[
"double-quoted with a dangling backslash",
'OPENAI_API_KEY="opaque first second\\\nsafe diagnostic',
'OPENAI_API_KEY="<REDACTED>"\nsafe diagnostic',
],
[
"single-quoted with a dangling backslash",
"OPENAI_API_KEY='opaque first second\\\nsafe diagnostic",
"OPENAI_API_KEY='<REDACTED>'\nsafe diagnostic",
],
[
"double-quoted before a bare carriage return",
'OPENAI_API_KEY="opaque first second\rsafe diagnostic',
'OPENAI_API_KEY="<REDACTED>"\rsafe diagnostic',
],
[
"single-quoted before a bare carriage return",
"OPENAI_API_KEY='opaque first second\rsafe diagnostic",
"OPENAI_API_KEY='<REDACTED>'\rsafe diagnostic",
],
[
"double-quoted with a dangling backslash before a bare carriage return",
'OPENAI_API_KEY="opaque first second\\\rsafe diagnostic',
'OPENAI_API_KEY="<REDACTED>"\rsafe diagnostic',
],
[
"single-quoted with a dangling backslash before a bare carriage return",
"OPENAI_API_KEY='opaque first second\\\rsafe diagnostic",
"OPENAI_API_KEY='<REDACTED>'\rsafe diagnostic",
],
])(
"fails closed at line end for an unterminated %s environment assignment (#9863)",
(_case, assignment, expectedFull) => {
const full = redactFull(assignment);
const sensitive = redactSensitiveText(assignment);
const suffix = assignment.includes("\r") ? "\rsafe diagnostic" : "\nsafe diagnostic";

expect(full).toBe(expectedFull);
expect(sensitive).toBe(`OPENAI_API_KEY=<REDACTED>${suffix}`);
},
);

Comment thread
apurvvkumaria marked this conversation as resolved.
it("preserves benign structured keys containing pass", () => {
const benign = {
compass: "north",
Expand Down
21 changes: 20 additions & 1 deletion src/lib/security/redact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ const SENSITIVE_ENV_ASSIGNMENT_KEYS = [
...listMessagingCredentialMetadata().map((credential) => credential.providerEnvKey),
];

const DOUBLE_QUOTED_SECRET_ASSIGNMENT_VALUE = String.raw`"(?:\\.|[^"\\\r\n])*(?:"|(?:\\)?(?=\r\n?|\n|$))`;
const SINGLE_QUOTED_SECRET_ASSIGNMENT_VALUE = String.raw`'(?:\\.|[^'\\\r\n])*(?:'|(?:\\)?(?=\r\n?|\n|$))`;
const SENSITIVE_ENV_ASSIGNMENT_VALUE = `(?:${DOUBLE_QUOTED_SECRET_ASSIGNMENT_VALUE}|${SINGLE_QUOTED_SECRET_ASSIGNMENT_VALUE}|\\S+)`;
const SENSITIVE_ENV_ASSIGNMENT_PATTERN = new RegExp(
`(${SENSITIVE_ENV_ASSIGNMENT_KEYS.map(escapeRegExp).join("|")})=\\S+`,
`(${SENSITIVE_ENV_ASSIGNMENT_KEYS.map(escapeRegExp).join("|")})=${SENSITIVE_ENV_ASSIGNMENT_VALUE}`,
"gi",
);

Expand Down Expand Up @@ -100,6 +103,19 @@ export function writeRedactedResult(

// ── Full redaction (debug.ts style) ─────────────────────────────

const UNDERSCORE_SECRET_ASSIGNMENT_KEY_SOURCE =
"(?:[A-Za-z0-9]{1,128}_(?:key|token|secret|credential|password|passwd|pass)|(?:x[-_])?api[-_]key|token|secret|credential|password|passwd|pass)";
const CAMEL_SECRET_ASSIGNMENT_KEY_SOURCE =
"(?:[A-Za-z0-9]{1,128}(?:Token|Secret|Credential)|[A-Za-z0-9]{0,128}(?:[Aa]ccess|[Rr]efresh|[Cc]lient|[Bb]earer|[Aa]uth|[Aa][Pp][Ii]|[Pp]rivate|[Ss]igning|[Ss]ession|[Bb]ot|[Aa]pp|[Rr]esolved)Key|[A-Za-z0-9]{1,128}(?:Password|Passwd|Pass))";

function quotedSecretAssignmentPatterns(keySource: string, flags: string): [RegExp, string][] {
const prefix = `((?:^|[^A-Za-z0-9])${keySource}["']?(?:[ \\t]{0,32}[=:][ \\t]{0,32}|[ \\t]{1,32}))`;
return [
[new RegExp(`${prefix}${DOUBLE_QUOTED_SECRET_ASSIGNMENT_VALUE}`, flags), '$1"<REDACTED>"'],
[new RegExp(`${prefix}${SINGLE_QUOTED_SECRET_ASSIGNMENT_VALUE}`, flags), "$1'<REDACTED>'"],
];
}

const FULL_REDACT_PATTERNS: [RegExp, string][] = [
...SECRET_BLOCK_PATTERNS.map((p): [RegExp, string] => [
new RegExp(p.source, p.flags),
Expand Down Expand Up @@ -142,6 +158,9 @@ const FULL_REDACT_PATTERNS: [RegExp, string][] = [
"$1 <REDACTED>",
],
[/(\b(?:cookie|set-cookie)[ \t]*[:=][ \t]*)[^\r\n]*/gi, "$1<REDACTED>"],
...quotedSecretAssignmentPatterns(UNDERSCORE_SECRET_ASSIGNMENT_KEY_SOURCE, "gi"),
...quotedSecretAssignmentPatterns(CAMEL_SECRET_ASSIGNMENT_KEY_SOURCE, "g"),
...quotedSecretAssignmentPatterns("KEY", "g"),
[
/((?:^|[^A-Za-z0-9])(?:[A-Za-z0-9]{1,128}_(?:key|token|secret|credential|password|passwd|pass)|(?:x[-_])?api[-_]key|token|secret|credential|password|passwd|pass)["']?(?:[ \t]{0,32}[=:][ \t]{0,32}|[ \t]{1,32})["']?)[^\s'"]+((?:"|')?)/gi,
"$1<REDACTED>$2",
Expand Down
Loading