mirror of
https://github.com/hoppscotch/hoppscotch.git
synced 2026-10-24 12:11:35 +08:00
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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user