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
20 changes: 18 additions & 2 deletions apps/docs/src/content/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,16 +169,32 @@ A refused write names the reason and the unmask that lifts it. The panel, `form-

<ngmd-accordion>
<ngmd-accordion-item title="The full list of secret words">
password, passwd, passphrase, passcode, pass, pwd, secret, token, otp, totp, pin, cvv, cvc, csc, ssn, iban, card, cc, credential, credentials, cookie, authorization and jwt. Names are split on camelCase and punctuation, so <code>userPassword</code> and <code>card_number</code> both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber, securityCode, sessionId and sessionKey match as well. Every inspector uses this list.
password, passwd, passphrase, passcode, pass, pwd, secret, token, otp, totp, pin, cvv, cvc, csc, ssn, iban, card, cc, credential, credentials, cookie, authorization and jwt. Names are split on camelCase and punctuation, so <code>userPassword</code> and <code>card_number</code> both match. The pairs apiKey, privateKey, secretKey, accessKey, ccNum, ccNumber, securityCode, sessionId, sessionKey, authHeader, authKey, authCode and basicAuth match as well, and so does a name that is only bearer. <code>auth</code> on its own is not secret, so an <code>auth</code> state slice stays readable, but credentials inside it are still masked by value (see <a href="#credentials-inside-values">Credentials inside values</a>). Every inspector uses this list.
</ngmd-accordion-item>
</ngmd-accordion>

### Credentials inside values

Some credentials are hidden wherever they appear in a string, whatever the key is called. Every inspector, the panel and the agent tools get the same masking:

| Value | Shown as |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| A URL with a password, such as `https://alice:pw@host` | `https://alice:[redacted]@host` |
| `Basic` followed by base64 of `user:password` | `Basic [redacted]` |
| `Digest` followed by its `name="value"` parameters | `Digest [redacted]` |
| `Token` followed by an opaque token with letters and digits | `Token [redacted]` |
| `Bearer` followed by a token | `Bearer [redacted]` |
| Any of `Basic`, `Bearer`, `Digest`, `Token`, `Negotiate`, `NTLM`, `ApiKey` and similar after `Authorization:` or `Proxy-Authorization:` | the scheme, then `[redacted]` |
| A JWT | `[redacted]` |

The URL user stays visible, so you can tell which account a call uses. Ordinary words are left alone: `Basic plan`, `Token expired` and `Digest of the week` don't match.

### Router

These are replaced with `[redacted]` in URLs, params, data and messages:

- query, matrix and fragment keys that look secret (token, password, api key, code, sig, session, jwt and similar), including inside encoded return URLs,
- JWTs, bearer tokens and long opaque tokens,
- URL passwords, `Authorization` credentials, JWTs, bearer tokens and long opaque tokens,
- route params with secret-looking names.

A secret route param is known from the route config before a navigation is recognized, so link targets and a navigation that was already running when the overlay attached are masked too. A navigation that fails before that (for example inside a lazy route that failed to load) can still show it in its URL. JWTs are masked before long values are cut, so a long token never leaves a readable start behind.
Expand Down
2 changes: 1 addition & 1 deletion docs/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ An offline HTML build of the panel over the source scan, written by `pangular bu
_Avoid_: export, snapshot, static site

**Redaction**:
Masking values before they reach the panel or an agent: field names that look secret (plus `redaction.secretNames`), JWTs and bearer tokens. It runs in `serialize` and the redaction helpers, and `redaction.unmask` lists the names to show anyway.
Masking values before they reach the panel or an agent: field names that look secret (plus `redaction.secretNames`), URL passwords, `Authorization` credentials (`Basic`, `Digest`, `Token`), JWTs and bearer tokens. It runs in `serialize` and the redaction helpers, and `redaction.unmask` lists the names to show anyway.
_Avoid_: sanitizing, scrubbing, hiding

**One-time code**:
Expand Down
166 changes: 166 additions & 0 deletions packages/devtools/src/__tests__/credential-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { redactMessage as serverLogMessage } from '../analog-server-log.ts';
import { REDACTED, isRedactedKey, isSecretKey, redactMessage } from '../forms-privacy.ts';
import type { HttpCall } from '../http-rules.ts';
import { redactCall, redactPreview } from '../http-redact.ts';
import { serialize as ngrxSerialize } from '../ngrx-shared.ts';
import {
isSecretKey as isRouterSecretKey,
redactRecord,
redactText,
redactUrl,
} from '../router.ts';
import { serialize } from '../serialize.ts';

const basic = `Basic ${btoa('alice:hunter22')}`;

describe('URL passwords', () => {
const url = 'https://alice:hunter22@api.example.com/v1/users?page=2';

it('hides the password and keeps the user in every helper', () => {
const safe = `https://alice:${REDACTED}@api.example.com/v1/users?page=2`;
expect(redactUrl(url)).toBe(safe);
expect(redactText(url)).toBe(safe);
expect(redactMessage(url)).toBe(safe);
expect(serialize(url)).toBe(safe);
expect(ngrxSerialize({ dsn: url })).toEqual({ dsn: safe });
expect(serverLogMessage(`GET ${url}`)).toBe(`GET ${safe}`);
});

it('hides it in recorded HTTP calls and inside text', () => {
const call = { url, pageUrl: url, error: `failed to reach ${url}` } as HttpCall;
const safe = redactCall(call);
for (const text of [safe.url, safe.pageUrl, safe.error]) {
expect(text).toContain(`alice:${REDACTED}@`);
expect(text).not.toContain('hunter22');
}
expect(redactPreview(JSON.stringify({ db: 'postgres://app:s3cr3t@db:5432/x' }))).toBe(
JSON.stringify({ db: `postgres://app:${REDACTED}@db:5432/x` }),
);
expect(redactMessage('mongodb+srv://u:p%40ss@cluster0.example.net/db')).toBe(
`mongodb+srv://u:${REDACTED}@cluster0.example.net/db`,
);
});

it('leaves URLs without a password alone', () => {
for (const text of [
'ssh://git@github.com/org/repo.git',
'https://example.com/users/@alice',
'http://localhost:4200/a:b@c',
'mailto:alice@example.com',
'https://example.com:8443/path',
]) {
expect(redactUrl(text), text).toBe(text);
expect(redactMessage(text), text).toBe(text);
}
});
});

describe('Authorization credentials', () => {
it('hides Basic, Digest and Token credentials and keeps the scheme', () => {
expect(redactMessage(basic)).toBe(`Basic ${REDACTED}`);
expect(redactMessage('Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b')).toBe(
`Token ${REDACTED}`,
);
expect(
redactMessage(
'Digest username="alice", realm="api", nonce="dcd98b", response="6629fae49393a05397450978507c4ef1"',
),
).toBe(`Digest ${REDACTED}`);
expect(serialize(`sent ${basic.replace('Basic', 'basic')}`)).toBe(`sent basic ${REDACTED}`);
});

it('hides any known scheme after an Authorization header name', () => {
expect(redactText('Authorization: Negotiate YIIBhwYGKwYBBQUCoIIBezCCAXeg')).toBe(
`Authorization: Negotiate ${REDACTED}`,
);
expect(serverLogMessage('proxy-authorization=NTLM TlRMTVNTUAABAAAAB4IIog')).toBe(
`proxy-authorization=NTLM ${REDACTED}`,
);
expect(redactPreview('{"headers":{"Authorization":"Basic abc')).not.toContain('abc');
});

it('hides credentials in values whatever the key is called', () => {
expect(serialize({ header: basic, auth: basic })).toEqual({
header: `Basic ${REDACTED}`,
auth: `Basic ${REDACTED}`,
});
expect(redactRecord({ h: 'Token abcd1234efgh5678' })).toEqual({ h: `Token ${REDACTED}` });
});

it('leaves ordinary words alone', () => {
for (const text of [
'Basic usage of the API',
'Basic plan',
'Token expired, please sign in again',
'Token refresh failed',
'Digest of the week',
'authorization: required for this endpoint',
]) {
expect(redactMessage(text), text).toBe(text);
expect(redactText(text), text).toBe(text);
}
});
});

describe('secret key names', () => {
const masked = [
'authorization',
'Authorization',
'proxy-authorization',
'bearer',
'Bearer',
'bearerToken',
'x-api-key',
'apiKey',
'api_key',
'access_token',
'accessToken',
'refresh_token',
'client_secret',
'clientSecret',
'authToken',
'authHeader',
'authKey',
'auth_code',
'basicAuth',
];
const kept = [
'author',
'authorName',
'authors',
'authored',
'authority',
'authorize',
'authorized',
'oauthProvider',
'authStatus',
'isAuthenticated',
'keyboard',
'monkey',
];

it('masks credential-looking keys', () => {
for (const key of masked) {
expect(isSecretKey(key), key).toBe(true);
expect(isRedactedKey(key), key).toBe(true);
expect(isRouterSecretKey(key), key).toBe(true);
}
expect(serialize({ bearer: 'abc', apiKey: 'k' })).toEqual({
bearer: REDACTED,
apiKey: REDACTED,
});
});

it('keeps ordinary names that only start like a secret one', () => {
for (const key of kept) {
expect(isSecretKey(key), key).toBe(false);
expect(isRedactedKey(key), key).toBe(false);
}
expect(serialize({ author: 'Ada', authorName: 'Ada Lovelace' })).toEqual({
author: 'Ada',
authorName: 'Ada Lovelace',
});
});
});
3 changes: 2 additions & 1 deletion packages/devtools/src/analog-server-log.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { redactCredentials } from './credential-redact.ts';
import { JWT, isRedactedKey } from './forms-privacy.ts';
import { redactJsonText } from './json-text-redact.ts';

Expand All @@ -18,7 +19,7 @@ function queryKey(key: string): string {
}

export function redactMessage(text: string): string {
return text
return redactCredentials(text, '[redacted]')
.replace(JWT, '[redacted]')
.replace(BEARER, 'Bearer [redacted]')
.replace(QUERY_PAIR, (whole, sep: string, key: string) => {
Expand Down
53 changes: 53 additions & 0 deletions packages/devtools/src/credential-redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Credentials that ride inside text rather than under a secret key: the
* password in a URL's userinfo (`https://user:pass@host`) and the credential of
* an `Authorization` value (`Basic …`, `Digest …`, `Token …`). Bearer tokens
* and JWTs have their own patterns next to the callers.
*/

/** `scheme://user:password@`; the user is kept, the password is hidden. */
const USERINFO = /\b([a-z][a-z\d+.-]*:\/\/)([^\s/?#@:"'<>\\]*):([^\s/?#@"'<>\\]+)@/gi;

/** The credential after a known scheme in an `Authorization:` or `authorization=` value. */
const AUTH_HEADER =
/\b((?:proxy-)?authorization)(["']?\s*[:=]\s*["']?)(Basic|Bearer|Digest|Token|Negotiate|NTLM|ApiKey|Key|DPoP|Hawk|HOBA|Mutual|AWS4-HMAC-SHA256|SCRAM-SHA-(?:1|256)|vapid)([ \t]+)([^\s"'\\][^"'\\\r\n]*)/gi;

/** `Basic <base64 of user:password>`. */
const BASIC = /\b(Basic)([ \t]+)([A-Za-z\d+/]{4,}={0,2})(?![\w+/=])/gi;

/** `Digest name="value", …`: the parameter list carries the response hash. */
const DIGEST =
/\b(Digest)([ \t]+)\w+=(?:"[^"\r\n]*"|[^\s,"]+)(?:\s*,\s*\w+=(?:"[^"\r\n]*"|[^\s,"]+))*/gi;

/** `Token <opaque>` as used by Django REST Framework and GitHub. */
const TOKEN = /\b(Token)([ \t]+)([\w.~+/-]{8,}=*)/g;

function decodesToPair(text: string): boolean {
try {
return typeof atob === 'function' && atob(text).includes(':');
} catch {
return false;
}
}

function looksOpaque(text: string): boolean {
return /\d/.test(text) && /[A-Za-z]/.test(text);
}

export function redactCredentials(text: string, mask: string): string {
if (!text) return text;
return text
.replace(USERINFO, (_, scheme: string, user: string) => `${scheme}${user}:${mask}@`)
.replace(
AUTH_HEADER,
(whole, name: string, sep: string, scheme: string, gap: string, credential: string) =>
credential.startsWith(mask) ? whole : `${name}${sep}${scheme}${gap}${mask}`,
)
.replace(BASIC, (whole, scheme: string, gap: string, credential: string) =>
decodesToPair(credential) ? `${scheme}${gap}${mask}` : whole,
)
.replace(DIGEST, (_, scheme: string, gap: string) => `${scheme}${gap}${mask}`)
.replace(TOKEN, (whole, scheme: string, gap: string, credential: string) =>
looksOpaque(credential) ? `${scheme}${gap}${mask}` : whole,
);
}
11 changes: 10 additions & 1 deletion packages/devtools/src/forms-privacy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { redactCredentials } from './credential-redact.ts';

export const REDACTED = '[redacted]';

export type RedactReason = 'key' | 'input-type' | 'autocomplete' | 'marker' | 'parent' | 'config';
Expand Down Expand Up @@ -55,6 +57,11 @@ const SECRET_PAIRS = new Set([
'securitycode',
'sessionid',
'sessionkey',
'authheader',
'authkey',
'authcode',
'basicauth',
'bearer',
]);

const SECRET_AUTOCOMPLETE = /password|one-time-code|cc-/i;
Expand Down Expand Up @@ -170,7 +177,9 @@ function safeQuery(element: Element, selector: string): boolean {
}

export function redactMessage(text: string, secrets: Iterable<string> = []): string {
let out = text.replace(JWT, REDACTED).replace(BEARER, `Bearer ${REDACTED}`);
let out = redactCredentials(text, REDACTED)
.replace(JWT, REDACTED)
.replace(BEARER, `Bearer ${REDACTED}`);
const longestFirst = [...secrets].filter((secret) => secret.length >= 3);
longestFirst.sort((a, b) => b.length - a.length);
for (const secret of longestFirst) out = out.split(secret).join(REDACTED);
Expand Down
6 changes: 4 additions & 2 deletions packages/devtools/src/router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { redactCredentials } from './credential-redact.ts';
import { JWT } from './forms-privacy.ts';
import { REDACTED, isSensitive, serializeFormValue } from './forms.ts';
import { clip } from './text.ts';
Expand Down Expand Up @@ -235,7 +236,8 @@ function segmentForm(secret: string): string {

/**
* Hides the values of secret-looking query, matrix and fragment keys
* (`?token=…`, `;api_key=…`, `#access_token=…`), JWTs and bearer tokens, and
* (`?token=…`, `;api_key=…`, `#access_token=…`), URL passwords, `Authorization`
* credentials (`Basic`, `Digest`, `Token`), JWTs and bearer tokens, and
* any of the given secret route param values, wherever they appear in a URL or
* a message. In `url` mode a value runs to the next separator and long opaque
* tokens (not UUIDs or ULIDs) are hidden too; in messages it also stops at quotes.
Expand All @@ -246,7 +248,7 @@ export function redactText(
mode: 'url' | 'text' = 'text',
depth = 0,
): string {
let out = text.replace(
let out = redactCredentials(text, REDACTED).replace(
mode === 'url' ? URL_PAIR : TEXT_PAIR,
(match, sep: string, key: string, value: string) => {
if (isSecretKey(decode(key))) return `${sep}${key}=${REDACTED}`;
Expand Down
Loading