fix: harden the chat pipeline and secret redaction

The client's secret filter ran a quadratic regex, so one crafted message
could stall the page for a minute. It now runs in linear time, catches
more credential shapes and leaves placeholders and script references
alone.
This commit is contained in:
nivedin
2026-09-24 15:12:54 +05:30
committed by Nivedin
parent 86f2a3c73f
commit 51bce761f4
2 changed files with 755 additions and 38 deletions
@@ -5,6 +5,243 @@ import {
replaceSensitiveChatValues,
} from "../secret-references"
// Mirrors the backend REDACTED_CASES; its spec runs both copies on them.
const REDACTED_CASES: Array<[string, string]> = [
// Dotted and call-shaped values are credentials unless a script root leads.
["refresh_token=ya29.a0AfH6SMBxyz", "refresh_token=[REDACTED]"],
[
"GET /me?access_token=ya29.a0AfH6SMBxyz&alt=json",
"GET /me?access_token=[REDACTED]&alt=json",
],
[
"GET /v4?access_token=sk.eyJ1IjoiYWJj.aBcD",
"GET /v4?access_token=[REDACTED]",
],
["- access_token: pk.eyJ1IjoiYWJj.aBcD", "- access_token: [REDACTED]"],
["api_key=abc.def123", "api_key=[REDACTED]"],
["password: john.doe1990", "password: [REDACTED]"],
["client_secret=abc123.xyz", "client_secret=[REDACTED]"],
["- X-API-Key: key(1234567", "- X-API-Key: [REDACTED]"],
["password=$uperSecret", "password=[REDACTED]"],
// A body cut off inside a quoted value.
['password: "unterminated', 'password: "[REDACTED]'],
['{"password": "hunter2secretvalue…[truncated]', '{"password": "[REDACTED]'],
["password: 'hunter2", "password: '[REDACTED]"],
// Header and param lines run to their end, `&` and spaces included.
["- password: correct horse battery", "- password: [REDACTED]"],
["- password: P@ss&word!9", "- password: [REDACTED]"],
["- X-API-Key: ab&cdef0123456789", "- X-API-Key: [REDACTED]"],
["password: P@ss&word", "password: [REDACTED]"],
// Capitalised words are types only inside SDL.
["api_key: Welcome!", "api_key: [REDACTED]"],
["- password: Secret!", "- password: [REDACTED]"],
["password=Welcome!", "password=[REDACTED]"],
["password: 123456", "password: [REDACTED]"],
["login(password: hunter2)", "login(password: [REDACTED])"],
["f(g(password: hunter2))", "f(g(password: [REDACTED]))"],
// A GraphQL head left open (truncation, prose) exempts nothing after it.
[
'Body (application/json):\n{"query":"query Orders { orders { edges { node { id…[truncated]\n\n### Latest response\nBody (truncated):\naccess_token=Zq81hVb7Pp2mLx0rT9&token_type=bearer',
'Body (application/json):\n{"query":"query Orders { orders { edges { node { id…[truncated]\n\n### Latest response\nBody (truncated):\naccess_token=[REDACTED]&token_type=bearer',
],
[
"Send the input payload { grant_type\nBody:\ngrant_type=password&password=Hunter2Pass",
"Send the input payload { grant_type\nBody:\ngrant_type=password&password=[REDACTED]",
],
// A line-start key takes its whole line, spaces, `&`, `,` and `'` included.
["password: correct horse battery", "password: [REDACTED]"],
["DB_PASSWORD=Tr0ub4dor&3xtra", "DB_PASSWORD=[REDACTED]"],
["SMTP_PASSWORD=abc,def,ghi123", "SMTP_PASSWORD=[REDACTED]"],
["password: O'Neil#2024x", "password: [REDACTED]"],
["- Api-Key : abc def", "- Api-Key : [REDACTED]"],
["token: >-\n QQ7IdH7fq2w9e8", "token: >-\n [REDACTED]"],
// Name and value in separate fields or arguments.
[
'[{"key": "Authorization", "value": "Bearer 9a8b7c6d5e4f3a2b1c0d"}]',
'[{"key": "Authorization", "value": "Bearer [REDACTED]"}]',
],
[
'{"Name":"/prod/db/password","Type":"SecureString","Value":"Hunter2Pass"}',
'{"Name":"/prod/db/password","Type":"SecureString","Value":"[REDACTED]"}',
],
[
'pw.env.set("api_key", "Q3xk9LmP2vR8tY6wZ1aB");',
'pw.env.set("api_key", "[REDACTED]");',
],
["['password' => 'Hunter2Pass']", "['password' => '[REDACTED]']"],
// Stringified JSON.
[
'{"body":"{\\"password\\":\\"Hunter2Pass\\"}"}',
'{"body":"{\\"password\\":\\"[REDACTED]\\"}"}',
],
[
'{\\"Authorization\\":\\"Bearer 9a8b7c6d5e4f\\"}',
'{\\"Authorization\\":\\"Bearer [REDACTED]\\"}',
],
// Any Authorization scheme keeps its word and loses its credential.
[
"- Authorization: ApiKey VnVhQ2ZHY0JDZGJr==",
"- Authorization: ApiKey [REDACTED]",
],
[
"- Authorization: SSWS 00QCjAl4MlV-WPXM",
"- Authorization: SSWS [REDACTED]",
],
[
'- Authorization: Token token="c2VjcmV0dG9rZW4"',
"- Authorization: Token [REDACTED]",
],
[
'- Authorization: Digest username="a", response="6629fae4"',
"- Authorization: Digest [REDACTED]",
],
// user:password outside `//user:pass@`.
[
"curl -u admin:S3cr3tP4ss https://api.example.com",
"curl -u admin:[REDACTED] https://api.example.com",
],
[
"REDIS_URL=redis://:Sup3rS3cret@cache:6379",
"REDIS_URL=redis://:[REDACTED]@cache:6379",
],
// More key names and shapes.
[
"GET /1/members/me?key=79d783ac00125ac1&token=ATTA123",
"GET /1/members/me?key=[REDACTED]&token=[REDACTED]",
],
[
"GET /b.txt?sv=2022&sig=AbCdEf%2BGh123",
"GET /b.txt?sv=2022&sig=[REDACTED]",
],
[
"AccountKey=54MhAbC==;EndpointSuffix=core.windows.net",
"AccountKey=[REDACTED];EndpointSuffix=core.windows.net",
],
["DB_PASS=Tr0ub4dorX9", "DB_PASS=[REDACTED]"],
["MYSQL_PWD=Tr0ub4dorX9", "MYSQL_PWD=[REDACTED]"],
["- X-Functions-Key: 54MhAbC==", "- X-Functions-Key: [REDACTED]"],
["PHPSESSID=79d783ac00125", "PHPSESSID=[REDACTED]"],
[
"https://hooks.slack.com/services/T0ABCDEFGH/B0ABCDEFGH/oeHS8i4byjbrdg1JxQ3E",
"https://hooks.slack.com/services/[REDACTED]",
],
[
"my token is github_pat_11AD9URBK0HU89S38HVLWI_VWQWabcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJ",
"my token is [REDACTED]",
],
["temporary key ASIAMVS3NDJQC14F4Z0U", "temporary key [REDACTED]"],
[
"client secret is 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",
"client secret is [REDACTED]",
],
[
'{"set-cookie": ["connect.sid=s%3AabcDEF123456; Path=/"]}',
'{"set-cookie": ["[REDACTED]"]}',
],
[
"-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,AB12\n\nMIIEvQ+/=\n-----END RSA PRIVATE KEY-----",
"[REDACTED]",
],
// Exemptions cover whole values only.
["DB_PASSWORD={Xy9!pQ7zR}", "DB_PASSWORD=[REDACTED]"],
["- password: [Xy9!pQ7zR]", "- password: [REDACTED]"],
["password=$UPER2024SECRET", "password=[REDACTED]"],
['{"password": "<<user>>Hunter2Secret"}', '{"password": "[REDACTED]"}'],
["Cookie: <<cookieVar>>; auth=abcdef123secret", "Cookie: [REDACTED]"],
["Password=my.pass;Server=db", "Password=[REDACTED];Server=db"],
["api_key={{apiKey}}Xy9", "api_key=[REDACTED]"],
// Short keys keep counts, paths and names; `*_PASS` stays strict.
["pass=hunter2", "pass=[REDACTED]"],
["DB_PASS=12345", "DB_PASS=[REDACTED]"],
["SIGNING_KEY=mysecretvalue", "SIGNING_KEY=[REDACTED]"],
["pwd: Tr0ub4dor&3", "pwd: [REDACTED]"],
["GET /v1?key=AbCdEf123456", "GET /v1?key=[REDACTED]"],
["GET /b.txt?sig=a1b2c3d4e5f6", "GET /b.txt?sig=[REDACTED]"],
// A placeholder in the user keeps its own colon.
[
"curl -u <<user>>:Hunter2Pass https://x",
"curl -u <<user>>:[REDACTED] https://x",
],
[
"curl -u admin<<suffix>>:Hunter2Pass https://x",
"curl -u admin<<suffix>>:[REDACTED] https://x",
],
]
// Mirrors the backend UNCHANGED_CASES.
const UNCHANGED_CASES = [
'{"token": []}',
'{"session": [1, 2], "items": []}',
'{"session_id": 12345, "token": 482913}',
"query { viewer { sessionToken: token } }",
"query { login(session: WEB) { id } }",
"mutation Refresh($token: String!, $session: SessionInput) { refresh(token: $token, session: $session) { accessToken } }",
"mutation Refresh(\n $token: String!\n) {\n refresh(token: $token) { id }\n}",
'{"query":"{ viewer { sessionToken: token } }"}',
"type Query {\n session: Session!\n token: AuthToken!\n}",
"if (token === undefined) return",
"const f = token => token.trim()",
"const session = await hopp.fetch(url)",
"let token = 0",
"const token = JSON.parse(pw.response.body).token",
'curl -H "x-api-key: $API_KEY"',
// The model's own scripts and docs.
'const body = pw.response.body;\nconst token = body.token;\npw.env.set("token", token);',
"const token = res.body.token",
"const t = { token: data.token }",
"let session = json.session;",
"let token = null;",
"const sessionId = cookies.session;",
'const token = localStorage.getItem("token")',
"token = await login(user, pass);",
// A closed call keeps its own `)`.
"const token = getToken()",
"if (ok) token = getToken()",
"login(password: getPw())",
"client(token=get_token())",
'const h = { Authorization: "Bearer " + pw.env.get("token") }',
'hopp.request.setHeader("Authorization", "Bearer " + token);',
"Documentation:\n- token: the refresh token\n- session: optional session name",
"Token: valid for one hour.",
// Pagination, tickers and statuses.
'{"session": "draft", "token": "USDC"}',
'{"limit": 20, "nextToken": "eyJ2ZXJzaW9uIjoyfQ"}',
"GET /drive/v3/files?pageToken=CAESBggDEgQIAhAB",
"- sellToken: WETH\n- buyToken: DAI",
'{"ClientToken": "a1b2c3d4-run-42"}',
// Paths and ARNs.
'{"SecretId": "arn:aws:secretsmanager:us-east-1:1:secret:prod/db"}',
"POST /v1/projects/p/secrets/db_password:addVersion",
"POST /v1/session:refresh",
// Cookie settings and placeholders.
'{"cookie": {"secure": true, "maxAge": 86400}}',
"- Cookie: session=<<session_id>>",
'{"token": "Bearer <<token>>"}',
"token: >-\n <<local-ref:secret_1>>",
// Refs the client minted for a curl user or URL userinfo.
"curl https://api.stripe.com/v1/charges -u <<local-ref:secret_1>>: -d amount=2000",
"curl -u <<local-ref:secret_2>>:<<local-ref:secret_1>> https://api.github.com/user",
"curl -u <<local-ref:secret_1>> https://x",
'curl_setopt($ch, CURLOPT_USERPWD, "<<local-ref:secret_1>>:");',
"git clone https://<<local-ref:secret_1>>@github.com/o/r.git",
'curl -b "session=<<sessionId>>" https://x',
// Postman templates.
"GET {{baseUrl}}/users\nX-Api-Key: {{apiKey}}",
"- X-Api-Key: {{apiKey}}",
"api_key={{apiKey}}",
"- token: {{ token }}",
"password: ${password}",
// Counts, directories and field names under short keys.
'{"results":{"pass":42,"fail":1}}',
"let pass = 0, fail = 0;",
"PWD=/home/runner/work",
"GET /search?q=test&key=relevance",
"- key: Content-Type\n value: application/json",
"GET /?sig=v1&expires=123",
"- Accept-Signature: sha256",
'{"sig": "sha256"}',
]
describe("replaceSensitiveChatValues", () => {
test("replaces a named Stripe secret while preserving its variable name", () => {
const values = new Map<string, string>()
@@ -120,4 +357,210 @@ describe("redactSensitiveChatValues", () => {
redactSensitiveChatValues("Authorization: Bearer <<stripeSecretKey>>")
).toBe("Authorization: Bearer <<stripeSecretKey>>")
})
// Mirrors the backend sanitizeChatContent cases.
test.each([
["password: Summer2024", "password: [REDACTED]"],
["- x-api-key: Q3xk9LmP2vR8tY6wZ1aB", "- x-api-key: [REDACTED]"],
["- X-API-Key: 4f3c2b1a9e8d7c6b5a4f", "- X-API-Key: [REDACTED]"],
["secret=null_but_really_a_secret", "secret=[REDACTED]"],
['{"password":"123456Secret!"}', '{"password":"[REDACTED]"}'],
['{"api_key":"3fa85f64-5717-4562"}', '{"api_key":"[REDACTED]"}'],
['"access_token": "1/fFAGRNJru1FTz7"', '"access_token": "[REDACTED]"'],
['{"password":"p@ss w0rd spaces"}', '{"password":"[REDACTED]"}'],
["- X-Auth-Token: 9f8e7d6c5b4a", "- X-Auth-Token: [REDACTED]"],
["- PRIVATE-TOKEN: glpat-abcdefghijkl", "- PRIVATE-TOKEN: [REDACTED]"],
["- X-Vault-Token: hvs.CAESIabc", "- X-Vault-Token: [REDACTED]"],
['{"token":"d41d8cd98f00b204"}', '{"token":"[REDACTED]"}'],
['{"session_id":"abc123"}', '{"session_id":"[REDACTED]"}'],
[
"- Ocp-Apim-Subscription-Key: ab12",
"- Ocp-Apim-Subscription-Key: [REDACTED]",
],
["GET /v1?token=abc123&page=2", "GET /v1?token=[REDACTED]&page=2"],
[
"GET https://admin:hunter2@api.example.com/v1",
"GET https://admin:[REDACTED]@api.example.com/v1",
],
[
'{"private_key":"-----BEGIN PRIVATE KEY-----\\nMIIEvQ\\n-----END PRIVATE KEY-----\\n"}',
'{"private_key":"[REDACTED]"}',
],
[
"key:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEvQ+/=\n-----END RSA PRIVATE KEY-----\nnext",
"key:\n[REDACTED]\nnext",
],
...REDACTED_CASES,
])("redacts %s", (input, expected) => {
expect(redactSensitiveChatValues(input)).toBe(expected)
})
test.each([
"const token = pw.response.body.access_token;",
"mutation { refresh(token: $token) { accessToken } }",
"token: {{token}} and api_key=${API_KEY}",
"max_tokens: 8192, input_tokens: 10, token_type: Bearer",
"type Login { token: String, user: User! }",
"GET https://<<user>>:<<pass>>@api.example.com",
...UNCHANGED_CASES,
])("leaves %s alone", (input) => {
expect(redactSensitiveChatValues(input)).toBe(input)
})
test.each([
["-eyJ", "-eyJ".repeat(100_000)],
["-eyJ with a segment", "-eyJaaaaaaaaaa".repeat(28_000)],
["open quotes", 'password:"'.repeat(40_000)],
["URL userinfo", "//a:" + "b:".repeat(190_000)],
["PEM headers", "-----BEGIN PRIVATE KEY-----".repeat(14_000)],
["closing parens", "token: " + ")".repeat(400_000) + "x"],
["header lines", ("\n- " + "a".repeat(115) + "token: x").repeat(3_300)],
["GraphQL heads", "query(".repeat(66_000)],
["open GraphQL blocks", "query {".repeat(57_000)],
["dotted calls", "password=" + "a.".repeat(200_000) + "("],
["ref users", " -u " + "<<a>>".repeat(80_000)],
["ref userinfo", "//" + "<<a>>".repeat(80_000)],
])("stays linear on adversarial %s input", (_name, input) => {
const started = Date.now()
redactSensitiveChatValues(input)
expect(Date.now() - started).toBeLessThan(200)
})
})
describe("replaceSensitiveChatValues on the widened rules", () => {
test("captures a whole quoted password, spaces included", () => {
const captured: string[] = []
const content = replaceSensitiveChatValues(
'set body {"password": "correct horse battery"}',
(secret) => {
captured.push(secret)
return `secret_${captured.length}`
}
)
expect(content).toBe('set body {"password": "<<local-ref:secret_1>>"}')
expect(captured).toEqual(["correct horse battery"])
})
test("captures a whole header value, & included", () => {
const captured: string[] = []
const content = replaceSensitiveChatValues(
"- password: correct&horse",
(secret) => {
captured.push(secret)
return `secret_${captured.length}`
}
)
expect(content).toBe("- password: <<local-ref:secret_1>>")
expect(captured).toEqual(["correct&horse"])
})
test("captures a dotted token instead of reading it as a script", () => {
const captured: string[] = []
replaceSensitiveChatValues(
"use refresh_token=ya29.a0AfH6SMBxyz",
(secret) => {
captured.push(secret)
return `secret_${captured.length}`
}
)
expect(captured).toEqual(["ya29.a0AfH6SMBxyz"])
})
// A ref must hold exactly the credential, or resolving it writes junk.
test.each([
['password: "correct horse battery"', ["correct horse battery"]],
// Unquoted, a line-start value ends at its first space: prose follows.
[
"api_key: 9f8e7d6c5b4a please add it as X-API-Key header",
["9f8e7d6c5b4a"],
],
["password: hunter2 (staging)", ["hunter2"]],
["API_KEY=abc123 # staging key", ["abc123"]],
["db:\n password: hunter2 # prod", ["hunter2"]],
// List entries too, and a scheme word stays with its credential.
["- X-Api-Key: 9f8e7d6c5b4a please add it as a header", ["9f8e7d6c5b4a"]],
[" - api_key: abc123 # staging", ["abc123"]],
["- password: hunter2 (staging)", ["hunter2"]],
["X-Auth-Token: Bearer abc123 please add it", ["Bearer abc123"]],
["- Authorization: ApiKey VnVhQ2ZHY0JD==", ["VnVhQ2ZHY0JD=="]],
[
"Authorization: Bearer sk_test_Secret and webhook whsec_Secret",
["sk_test_Secret", "whsec_Secret"],
],
["token: >-\n QQ7IdH7fq2w9e8", ["QQ7IdH7fq2w9e8"]],
["curl -u admin:S3cr3tP4ss https://api.example.com", ["S3cr3tP4ss"]],
['pw.env.set("api_key", "Q3xk9LmP2vR8tY6wZ1aB")', ["Q3xk9LmP2vR8tY6wZ1aB"]],
])("captures only the credential in %s", (input, expected) => {
const captured: string[] = []
replaceSensitiveChatValues(input, (secret) => {
captured.push(secret)
return `secret_${captured.length}`
})
expect(captured).toEqual(expected)
})
test.each([
"curl https://api.stripe.com/v1/charges -u sk_test_51StripeSecretToken: -d amount=2000",
`curl -u ghp_${"x".repeat(36)}:x-oauth-basic https://api.github.com/user`,
`git clone https://ghp_${"x".repeat(36)}@github.com/o/r.git`,
"api_key: 9f8e7d6c5b4a please add it as X-API-Key header",
"API_KEY=abc123 # staging key",
])("keeps the refs it mints for %s through a second pass", (input) => {
let id = 0
const minted = replaceSensitiveChatValues(input, () => `secret_${++id}`)
expect(minted).toContain("<<local-ref:secret_1>>")
expect(redactSensitiveChatValues(minted)).toBe(minted)
expect(replaceSensitiveChatValues(minted, () => "again")).toBe(minted)
})
// The backend cuts user text the same way, so these pass it unchanged.
test.each([
"X-Auth-Token: Bearer <<token>>",
"session_token: Basic <<b64>>",
"X-Auth-Token: Bearer <<token>> from env",
"X-Api-Key: <<apiKey>> (from env)",
"api_key: {{apiKey}} please add it as a header",
"pass: 3 of 5 tests",
"- X-Api-Key: <<local-ref:secret_1>> from env",
"- api_key: {{apiKey}} please add it",
])("mints no ref for %s", (input) => {
expect(replaceSensitiveChatValues(input, () => "minted")).toBe(input)
})
test.each([
"- X-Api-Key: 9f8e7d6c5b4a please add it as a header",
"X-Auth-Token: Bearer abc123 please add it",
])("keeps the ref it mints for %s on a second pass", (input) => {
const minted = replaceSensitiveChatValues(input, () => "secret_1")
expect(minted).toContain("<<local-ref:secret_1>> please add it")
expect(replaceSensitiveChatValues(minted, () => "again")).toBe(minted)
})
test("keeps the instruction after a line-start credential", () => {
expect(
replaceSensitiveChatValues(
"api_key: 9f8e7d6c5b4a please add it as X-API-Key header",
() => "secret_1"
)
).toBe("api_key: <<local-ref:secret_1>> please add it as X-API-Key header")
// Context keeps redacting the whole line.
expect(redactSensitiveChatValues("password: hunter2 (staging)")).toBe(
"password: [REDACTED]"
)
})
test("turns a URL password into a reference the client can resolve", () => {
const captured: string[] = []
const content = replaceSensitiveChatValues(
"set url https://admin:hunter2@api.example.com",
(secret) => {
captured.push(secret)
return `secret_${captured.length}`
}
)
expect(content).toBe(
"set url https://admin:<<local-ref:secret_1>>@api.example.com"
)
expect(captured).toEqual(["hunter2"])
})
})
@@ -2,46 +2,178 @@ const LOCAL_SECRET_REFERENCE = /^<<local-ref:([A-Za-z0-9_-]+)>>$/
/** Every `<<local-ref:id>>` occurrence inside a longer string. */
export const LOCAL_SECRET_REFERENCE_GLOBAL = /<<local-ref:([A-Za-z0-9_-]+)>>/g
const REDACTED_VALUE = "[REDACTED]"
export const REDACTED_VALUE = "[REDACTED]"
// Every rule below mirrors the backend sanitizer in ai-experiments.service.ts
// byte-for-byte; its spec checks they match.
/**
* Values that may stay visible: environment placeholders (<<name>>, <<$fn>>)
* and existing client-local references (<<local-ref:id>>) — both are opaque
* to the model. Mirrors the backend sanitizer in ai-experiments.service.ts.
* to the model.
*/
const SAFE_VALUE_PREFIX =
/^<<(?:local-ref:[A-Za-z0-9_-]+|\$?[A-Za-z][A-Za-z0-9_.-]*|_[A-Za-z0-9_.-]*)>>/
const SENSITIVE_TEMPLATE_PREFIX = /^<<(?:sk_|rk_|whsec_|sk-ant-)/i
/** JSON/YAML literals that follow a keyword used as a FIELD NAME, not a value. */
const STRUCTURAL_LITERAL = /^(?:true|false|null|\d+(?:\.\d+)?|[[{])/
/** GraphQL SDL type references after a field/argument name (`password: String!`). */
const TYPE_REFERENCE = /^\[?[A-Z][A-Za-z0-9_]*!?\]?!?$/
const isSafeValue = (value: string) =>
SAFE_VALUE_PREFIX.test(value) && !SENSITIVE_TEMPLATE_PREFIX.test(value)
const SAFE_TEMPLATE =
/<<(?:local-ref:[A-Za-z0-9_-]+|\$?[A-Za-z][A-Za-z0-9_.-]*|_[A-Za-z0-9_.-]*)>>/g
const SENSITIVE_TEMPLATE = /<<(?:sk_|rk_|whsec_|sk-ant-)/i
/** An `Authorization` scheme word, which is not itself a secret. */
const AUTH_SCHEME =
/^`?(?:Bearer|Basic|Token|Digest|Negotiate|NTLM|OAuth|ApiKey|SSWS|Bot|SharedKey|Signature|AWS4-HMAC-SHA256)(?![\w-])[ \t]*/i
/** A value meaning the keyword is a field name (`"secret": true`, `"token": [1`). */
const STRUCTURAL_LITERAL =
/^(?:true|false|null|undefined|[[{]+(?:-?\d+(?:\.\d+)?|true|false|null)?[\]}]*)$/
/** GraphQL built-in scalars (`password: String!`); other SDL sits in a GRAPHQL_BLOCK. */
const TYPE_REFERENCE = /^\[?(?:String|Int|Float|Boolean|ID)!?\]?!?$/
/** References, not values: `pw.env.get(…)`, `await …`, `getToken()`, `$API_KEY`, `{{token}}`, `${TOKEN}`. */
const VALUE_REFERENCE =
/^(?:(?:pw|hopp|pm|request|response|env|JSON|Math|Date|crypto|this|btoa|atob|encodeURIComponent)(?:\??\.|\[|\().*|await|new|typeof|function|async|void|yield|[a-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\([^()\s]*\)[;)]*|\$\{?_?[A-Z]+(?:_[A-Z0-9]*)*\}?|\{\{[ \t]*[A-Za-z_][\w.-]*(?:[ \t]*\}\})?|\$\{[A-Za-z_][\w.-]*\}?)$/
/** Expressions (`a.b`, `f(`, `await f()`), references only where the key is code. */
const JS_REFERENCE =
/^(?!.*\beyJ)(?:(?:await|new|typeof|void|yield)\s.*|[A-Za-z_$][\w$]*(?:(?:\??\.[A-Za-z_$][\w$]*)+(?:[([].*)?|[([].*))$/
/** Text before a key that makes it code: `const token =`, `{ token:`. */
const DECLARATION = /(?:^|[^\w$.])(?:const|let|var)[ \t]+[\w$]*$/
const OBJECT_KEY = /[{,][ \t\r\n]*["']?[\w$-]*$/
/** A path or ARN segment (`/secrets/db_password:addVersion`), not a key. */
const PATH_SEGMENT = /[:/][\w.-]*$/
/** Keys that also name ids, tickers, statuses and prose (`"session": "active"`). */
const BROAD_KEY = /^(?:token|session)/i
const BROAD_VALUE =
/^(?:[a-z]{1,12}|[A-Z]{2,5}|(?![Bb]earer |[Bb]asic |[Tt]oken )[A-Za-z][a-z]{0,15}(?:[ \t]+[a-z]{1,15})+[.!?]?)$/
const NUMBER = /^-?\d+(?:\.\d+)?$/
/** Bare `pass`, `pwd`, `sig` and `key` also name counts, directories and fields. */
const WEAK_KEY = /^(?:(pass)|(pwd)|sig(?:nature)?|key)(?!\w)/i
const PATH_VALUE = /^~?\/[\w./-]*$/
const NAME_VALUE =
/^(?=.{1,32}$)(?:[a-z]+\d*(?:[-_.][a-z]+\d*)*|[A-Z][a-z]*(?:-[A-Z][a-z]*)*)$/
/** A YAML `|`/`>` indicator up to its first content line. */
const BLOCK_SCALAR = /^[|>][-+]?[0-9]?[ \t]*\r?\n[ \t]+/
/**
* The head of a GraphQL operation or type definition, up to its `{`. Bare
* values inside are variables, enums, aliases or types: literals are quoted.
*/
const GRAPHQL_BLOCK =
/(?:\b(?:query|mutation|subscription)(?:[ \t]{1,8}\w+)?[ \t]{0,8}(?:\([^()]{0,300}\))?|\bfragment[ \t]{1,8}\w+[ \t]{1,8}on[ \t]{1,8}\w+|\b(?:type|input|interface)[ \t]{1,8}\w+(?:[ \t]{1,8}implements[ \t]{1,8}[\w& \t]{1,80})?|(?:^|[\n"])(?=\{[ \t\r\n]{0,16}\w+[ \t\r\n]{0,16}[{(]))[ \t\r\n]{0,16}\{/g
/** Credential key names. `token` skips pagination and idempotency keys. */
const SECRET_KEY = `(?:api[-_]?key|private[-_]?key|subscription[-_]?key|(?:account|shared[-_]?access|encryption|signing|master|app|functions|access)[-_]?key|secret(?:[-_]?(?:access[-_]?)?key)?|passw(?:or)?d|(?<![A-Za-z])(?:pass(?:phrase)?|pwd|sig(?:nature)?)|sessid|(?<!(?:page|next|prev|previous|continuation|sync|client|sell|buy|resume|cursor|delta|idempotency)[-_]?)token|session(?:[-_]?id)?|(?<=[?&]|(?:^|[\\r\\n])[ \\t]{0,8}-[ \\t]{1,8})key)`
// Whitespace runs are bounded on purpose: an unbounded `\s*["']?\s*` pair
// backtracks quadratically on a keyword followed by a long whitespace run.
const KEY_SEP = `[ \\t]{0,8}["']?[ \\t]{0,8}[:=][ \\t]{0,8}["']?`
const KEY_SEP = `[ \\t]{0,8}\\\\?["']?[ \\t]{0,8}(?::(?!:)|=(?![=>]))[ \\t]{0,8}\\\\?["']?`
/** `Authorization: <scheme> <credential>` — the scheme stays, the credential goes. */
/** PEM private keys, armor headers included, also JSON-escaped or cut off. */
const PRIVATE_KEY_BLOCK =
/-----BEGIN [A-Z ]{0,40}PRIVATE KEY[A-Z ]{0,10}-----(?:(?<=[\r\n]|\\n)[A-Za-z][\w-]{0,30}:[^\r\n\\]{0,200}|[A-Za-z0-9+/=\s]|\\[nr])*(?:-----END [A-Z ]{0,40}PRIVATE KEY[A-Z ]{0,10}-----)?/g
/** The password in `scheme://user:password@host`; a `<<ref>>` user stays whole. */
const URL_PASSWORD =
/(\/\/(?:<<[\w$.:-]{1,120}>>|(?!<<)[^\s/?#@:])*:)([^\s/?#@]+)(?=@)/g
/** curl/httpie credentials: `-u user:pass`, `--oauth2-bearer t`, `-b k=v`; refs stay whole. */
const CLI_CREDENTIAL =
/((?:^|[\s(])(?:(?:-u|--user|-a|--auth|--proxy-user)[ \t=]{1,8}["']?(?:<<[\w$.:-]{1,120}>>|(?!<<)[^\s"':]){0,200}:|--oauth2-bearer[ \t=]{1,8}["']?|(?:-b|--cookie)[ \t=]{1,8}["']?(?=[^\s"']{0,200}=)|CURLOPT_USERPWD[ \t]*,[ \t]*["'](?:<<[\w$.:-]{1,120}>>|(?!<<)[^\s"':]){0,200}:))([^\s"']+)/g
/**
* `Authorization: <scheme> <credential>` — the scheme stays, the credential
* goes: one token, or a `k=v, k="v"` list (Digest, AWS4, `Token token="…"`).
*/
const AUTH_HEADER_ASSIGNMENT = new RegExp(
`((?:authorization|proxy-authorization)${KEY_SEP})((?:Bearer|Basic|Token|Negotiate|NTLM|OAuth)[ \\t]+)?((?:Digest|AWS4-HMAC-SHA256|Signature)[ \\t]+[^\\r\\n"'}]+|[^\\s,}"']+)`,
`((?:authorization|proxy-authorization)${KEY_SEP})(?:((?:AWS4-HMAC-SHA256|[A-Za-z][A-Za-z-]{0,15})[ \\t]+)((?:[\\w-]+=(?:\\\\?["'][^"'\\r\\n]*["']|[^\\s,"'}\\\\]*)(?:[ \\t]*,[ \\t]*)?)+|[^\\s,}"'\\\\]+)|([^\\s,}"']+))`,
"gi"
)
/** Cookie headers carry several `k=v` pairs — treat the whole value as one secret. */
const COOKIE_ASSIGNMENT = new RegExp(
`((?:cookie|set-cookie)${KEY_SEP})([^\\r\\n"'}]+)`,
`((?:cookie|set-cookie)${KEY_SEP})(\\[[^\\]]{0,4000}|[^\\r\\n"'}]+)`,
"gi"
)
/** Single-token credential assignments (`api_key=…`, `"password": "…"`). */
/** One quoted cookie in a `"set-cookie": [...]` array. */
const COOKIE_ITEM = /"([^"\\\r\n]*)"|'([^'\\\r\n]*)'/g
/** A quoted credential name whose value is a separate field or argument. */
const PAIR_NAME = `["'][^"'\\r\\n]{0,80}?(?:${SECRET_KEY}|(?:proxy-)?authorization|(?:set-)?cookie)["']`
/** `set("api_key", "…")`, `'password' => '…'`, `{"key": "Authorization", "value": "…"}`. */
const NAMED_VALUE = new RegExp(
`((?:\\([ \\t]*${PAIR_NAME}[ \\t]*,[ \\t]*|${PAIR_NAME}[ \\t]*=>[ \\t]*|["'](?:key|name)["'][ \\t]*:[ \\t]*${PAIR_NAME}[^{}[\\]]{0,160}?["']value["'][ \\t]*:[ \\t]*)["'])((?:[^"'\\\\\\r\\n]|\\\\.)+)`,
"gi"
)
/**
* Credential assignments (`x-auth-token: …`, `"password": "…"`), never `==`
* or `=>`. A quoted value (also `\"…\"` in stringified JSON) runs to its
* closing quote or the line end; a `- key: value` line, or a line-start
* `key: value` with no other `:`/`=`, to its end (YAML block scalars too);
* a bare `:` value past `&`, and a bare `=` value (a query param) up to it.
*/
const SENSITIVE_ASSIGNMENT = new RegExp(
`((?:x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|password|secret)${KEY_SEP})([^,\\s}"']+)`,
`(${SECRET_KEY}[ \\t]{0,8}\\\\?["']?[ \\t]{0,8}(?::(?!:)=?|=(?![=>]))[ \\t]{0,8})(?:\\\\"((?:[^"\\\\\\r\\n]|\\\\\\\\)+)(?:\\\\")?|"((?:[^"\\\\\\r\\n]|\\\\.?)+)"?|'((?:[^'\\\\\\r\\n]|\\\\.?)+)'?|(?<=(?:^|[\\r\\n])[ \\t]{0,8}-[ \\t]{1,8}[\\w.-]{1,120}[ \\t]{0,8}:[ \\t]{0,8})(\\S[^\\r\\n]*)|(?<=(?:^|[\\r\\n])[ \\t]{0,8}[\\w.-]{1,120}[ \\t]{0,8}[:=][ \\t]{0,8})([|>][-+]?[0-9]?(?:[ \\t]*\\r?\\n[ \\t]+\\S[^\\r\\n]*)+|\\S[^\\r\\n:=]*(?![^\\r\\n]))|((?<=:[ \\t]{0,8})[^,\\s}"']+|[^,\\s}"'&;]+))`,
"gi"
)
/** Bare credentials with a recognizable shape, wherever they appear. */
/**
* Bare credentials with a recognizable shape, wherever they appear. A JWT may
* only start a `[A-Za-z0-9_-]` run: restarting inside one made the scan quadratic.
*/
const STANDALONE_SECRET =
/\b(?:sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{20,}|sk_(?:test|live)_[A-Za-z0-9]+|rk_(?:test|live)_[A-Za-z0-9]+|whsec_[A-Za-z0-9]+|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g
/\b(?:sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{20,}|sk_(?:test|live)_[A-Za-z0-9]+|rk_(?:test|live)_[A-Za-z0-9]+|whsec_[A-Za-z0-9]+|(?:AKIA|ASIA)[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|xapp-\d-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|SG\.[\w-]{16,}\.[\w-]{16,}|GOCSPX-[\w-]{20,}|hf_[A-Za-z0-9]{30,}|npm_[A-Za-z0-9]{36})|(?<=hooks\.slack\.com\/services\/|discord(?:app)?\.com\/api\/webhooks\/)[\w/-]{20,}|(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/g
/** Placeholders only (a scheme word may lead): `<<token>>`, `Bearer <<t>>`. */
const isSafeValue = (value: string) => {
if (value === REDACTED_VALUE) return true
const rest = value.replace(AUTH_SCHEME, "")
const left = rest.replace(SAFE_TEMPLATE, "")
return (
left.length < rest.length &&
!/[A-Za-z0-9]/.test(left) &&
!SENSITIVE_TEMPLATE.test(rest)
)
}
/** A cookie whose every value is a placeholder (`session=<<sid>>`). */
const isSafeCookie = (value: string) =>
value.split(";").every((part) => {
const cookie = part.slice(part.indexOf("=") + 1).trim()
return !cookie || isSafeValue(cookie)
})
/** Length without a trailing `\` (the escape of a closing `\"`). */
const unescapedLength = (value: string) =>
value.endsWith("\\") ? value.length - 1 : value.length
/** A bare value that is syntax or a reference rather than a credential. */
const isPlainValue = (value: string, numeric: boolean) =>
isSafeValue(value) ||
STRUCTURAL_LITERAL.test(value) ||
TYPE_REFERENCE.test(value) ||
VALUE_REFERENCE.test(value) ||
(numeric && NUMBER.test(value))
/** `"pass": 3`, `PWD=/app`, `?sig=v1`, `- key: Accept`; `DB_PASS=` never. */
const isWeakValue = (prefix: string, lead: string, value: string) => {
const weak = lead.endsWith("_") ? null : WEAK_KEY.exec(prefix)
if (!weak) return false
return (weak[1] ? NUMBER : weak[2] ? PATH_VALUE : NAME_VALUE).test(value)
}
/**
* [start, end) of each GraphQL block, closed by its matching `}`. A head
* left open (prose, a truncated body) exempts nothing, and braces never
* pair across a `### ` context section.
*/
const graphQLBlocks = (content: string): Array<[number, number]> => {
const closers = new Map<number, number>()
const open: number[] = []
for (let i = 0; i < content.length; i++) {
const char = content[i]
if (char === "{") open.push(i)
else if (char === "}") {
const at = open.pop()
if (at !== undefined) closers.set(at, i + 1)
} else if (char === "\n" && content.startsWith("### ", i + 1)) {
open.length = 0
}
}
const blocks: Array<[number, number]> = []
GRAPHQL_BLOCK.lastIndex = 0
let match: RegExpExecArray | null
while ((match = GRAPHQL_BLOCK.exec(content))) {
const end = closers.get(match.index + match[0].length - 1)
if (end === undefined) continue
blocks.push([match.index, end])
GRAPHQL_BLOCK.lastIndex = end
}
return blocks
}
export const makeLocalSecretReference = (id: string): string =>
`<<local-ref:${id}>>`
@@ -59,31 +191,171 @@ export const containsLocalSecretReference = (value: string): boolean => {
* Rewrites every credential in `content` through `onSecret` (which returns
* the replacement text). Values that are already opaque to the model —
* environment placeholders and local references — are left untouched, as are
* JSON literals that merely follow a keyword used as a field name.
* field-name literals, SDL types, GraphQL syntax and script references.
* `firstWord`: a ref must hold exactly the credential, so an unquoted
* line-start or `- key:` value ends at its first space and the prose after
* it stays.
*/
const rewriteSensitiveValues = (
content: string,
onSecret: (secret: string) => string
): string =>
content
.replace(
AUTH_HEADER_ASSIGNMENT,
(match, prefix: string, scheme: string | undefined, secret: string) =>
isSafeValue(secret)
? match
: `${prefix}${scheme ?? ""}${onSecret(secret.trim())}`
onSecret: (secret: string) => string,
firstWord = false
): string => {
const text = content
.replace(PRIVATE_KEY_BLOCK, (secret: string) => onSecret(secret.trim()))
.replace(URL_PASSWORD, (match, prefix: string, secret: string) =>
isSafeValue(secret) ? match : `${prefix}${onSecret(secret)}`
)
.replace(COOKIE_ASSIGNMENT, (match, prefix: string, secret: string) =>
isSafeValue(secret) ? match : `${prefix}${onSecret(secret.trim())}`
)
.replace(SENSITIVE_ASSIGNMENT, (match, prefix: string, secret: string) =>
.replace(CLI_CREDENTIAL, (match, prefix: string, secret: string) =>
isSafeValue(secret) ||
STRUCTURAL_LITERAL.test(secret) ||
TYPE_REFERENCE.test(secret)
VALUE_REFERENCE.test(secret) ||
// `-b "sid=<<sid>>"`: a cookie of placeholders.
(/^[\s(]?(?:-b|--cookie)[ \t=]/.test(prefix) &&
secret.includes("<<") &&
isSafeCookie(secret))
? match
: `${prefix}${onSecret(secret)}`
)
.replace(
AUTH_HEADER_ASSIGNMENT,
(
match,
prefix: string,
scheme: string | undefined,
credential: string | undefined,
bare: string | undefined
) => {
const value = credential ?? bare ?? ""
const trimmed = value.replace(/[\s,]+$/, "")
const secret = trimmed.slice(0, unescapedLength(trimmed))
return isSafeValue(secret) ||
VALUE_REFERENCE.test(secret) ||
(!scheme && !secret.replace(AUTH_SCHEME, ""))
? match
: `${prefix}${scheme ?? ""}${onSecret(secret)}${value.slice(secret.length)}`
}
)
.replace(COOKIE_ASSIGNMENT, (match, prefix: string, value: string) => {
if (value.startsWith("[")) {
return `${prefix}${value.replace(
COOKIE_ITEM,
(item, double: string | undefined, single: string | undefined) => {
const cookie = double ?? single ?? ""
return isSafeCookie(cookie)
? item
: `${item[0]}${onSecret(cookie)}${item[0]}`
}
)}`
}
const trimmed = value.trimEnd()
const secret = trimmed.slice(0, unescapedLength(trimmed))
// `"cookie": {…}` is a settings object, not a header.
return !secret || secret.startsWith("{") || isSafeCookie(secret)
? match
: `${prefix}${onSecret(secret)}${value.slice(secret.length)}`
})
.replace(NAMED_VALUE, (match, prefix: string, secret: string) => {
const scheme = AUTH_SCHEME.exec(secret)?.[0] ?? ""
return scheme === secret ||
isSafeValue(secret) ||
VALUE_REFERENCE.test(secret)
? match
: `${prefix}${scheme}${onSecret(secret.slice(scheme.length))}`
})
// Matches arrive in order, so one cursor walks the blocks.
const blocks = graphQLBlocks(text)
let block = 0
const inGraphQL = (offset: number) => {
while (block < blocks.length && blocks[block][1] <= offset) block++
return block < blocks.length && blocks[block][0] <= offset
}
return text
.replace(
SENSITIVE_ASSIGNMENT,
(
match,
prefix: string,
escaped: string | undefined,
doubleQuoted: string | undefined,
singleQuoted: string | undefined,
entry: string | undefined,
line: string | undefined,
bare: string | undefined,
offset: number,
whole: string
) => {
const lead = whole.slice(Math.max(0, offset - 64), offset)
if (!prefix.includes("=") && PATH_SEGMENT.test(lead)) return match
const broad = BROAD_KEY.test(prefix)
const quoted = escaped ?? doubleQuoted ?? singleQuoted
if (quoted !== undefined) {
// Quoted values are data: only placeholders and plain words stay.
if (
isSafeValue(quoted) ||
(broad && BROAD_VALUE.test(quoted)) ||
isWeakValue(prefix, lead, quoted)
) {
return match
}
const start = prefix.length + (escaped === undefined ? 1 : 2)
return `${match.slice(0, start)}${onSecret(quoted)}${match.slice(start + quoted.length)}`
}
// A YAML block scalar: its indicator stays, its lines are the value.
const scalar = line && BLOCK_SCALAR.exec(line)?.[0]
if (scalar) {
const content = line.slice(scalar.length).trimEnd()
return isSafeValue(content)
? match
: `${prefix}${scalar}${onSecret(content)}${line.slice(scalar.length + content.length)}`
}
const full = entry ?? line ?? bare ?? ""
// A scheme word stays with the credential after it.
const cut = firstWord && bare === undefined
const scheme = cut ? (AUTH_SCHEME.exec(full)?.[0] ?? "") : ""
const value = cut
? `${scheme}${full.slice(scheme.length).split(/\s/, 1)[0]}`
: full
// A trailing `;`, or a `)` opened before the value, ends a call or
// statement; `getToken()` keeps its own.
let end = value.trimEnd().length
let open =
(value.match(/\(/g) ?? []).length - (value.match(/\)/g) ?? []).length
while (bare !== undefined && end) {
const char = value[end - 1]
if (char === ")" && open < 0) open++
else if (char !== ";") break
end--
}
const secret = value.slice(0, end)
const core = bare === undefined ? secret.replace(/[;,]$/, "") : secret
// `x = a.b;` is a statement; `Password=a.b;` a connection string.
const statement =
/[ \t]$/.test(prefix) &&
/^\)*[;,]/.test(
`${full.slice(core.length)}${whole[offset + match.length] ?? ""}`
)
const code =
statement || DECLARATION.test(lead) || OBJECT_KEY.test(lead)
const key = `${/[\w$-]*$/.exec(lead)?.[0] ?? ""}${/^[\w$-]*/.exec(prefix)?.[0] ?? ""}`
const plain = (numeric: boolean) =>
isPlainValue(core, numeric) ||
(broad && BROAD_VALUE.test(core)) ||
isWeakValue(prefix, lead, core) ||
(code && (JS_REFERENCE.test(core) || core === key))
const kept =
!core ||
(entry !== undefined
? plain(false)
: inGraphQL(offset) || plain(broad))
return kept
? match
: `${prefix}${onSecret(core)}${full.slice(core.length)}`
}
)
.replace(STANDALONE_SECRET, (secret: string) => onSecret(secret))
}
/** Removes credentials from context that has no need to retain their values. */
export const redactSensitiveChatValues = (content: string): string =>
@@ -97,6 +369,8 @@ export const replaceSensitiveChatValues = (
content: string,
createReference: (secret: string) => string
): string =>
rewriteSensitiveValues(content, (secret) =>
makeLocalSecretReference(createReference(secret))
rewriteSensitiveValues(
content,
(secret) => makeLocalSecretReference(createReference(secret)),
true
)