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
1 change: 1 addition & 0 deletions packages/devtools/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN
- The **Pipes** tab no longer marks two `| async` usages of a Promise, a Subject, an `asObservable()` view of one or an NgRx store select as duplicates, since their subscribers share one execution (#369). Any other source that two `| async` usages subscribe to shows as **possible duplicate subscription**, and `explain-pipe` says **Possible duplicate subscriptions**, because the page can't see `share()` or `shareReplay()` in a pipe.
- Browser DevTools show the app's own line as the caller of a `console` call again. The browser entries ship source maps that list the console tap in `x_google_ignoreList`, so its wrapper frame is skipped (#369).
- The Angular Native overlay gates the injector profiler like the NativeScript overlay: in a development build with no `window`, importing it defines one until Angular publishes its debug utilities, so the **Providers** lists fill (#371).
- Redaction leaks found by a corpus of fake secrets run through every redaction path are closed: a secret pair after another word and a colon (`Error: password: "…"`), the key after `Authorization: ApiKey` in an Analog server body that is not JSON, secret pairs in the fragment or matrix params of an Analog server message, headers with a camelCase secret name such as `newPassword`, and tokens used as NgRx state keys.

### Docs

Expand Down
160 changes: 160 additions & 0 deletions packages/devtools/src/__tests__/fixtures/redaction-corpus.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
{
"_comment": "Fake secrets in the shapes the redaction rules promise to hide. `shapes` hide themselves wherever they appear; `named` hide under a secret key. `secret` is the part that must never come out. None of these is a real credential. Provider keys that secret scanners flag (Stripe, Slack webhooks, GitLab) are tested in credential-redaction.test.ts instead.",
"shapes": [
{
"name": "JWT",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSJ9.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
"secret": "dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
},
{
"name": "unsigned JWT",
"value": "eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZGEtbG92ZWxhY2UifQ.",
"secret": "eyJzdWIiOiJhZGEtbG92ZWxhY2UifQ"
},
{
"name": "bearer token",
"value": "Bearer 9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c",
"secret": "9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c"
},
{
"name": "Basic credentials",
"value": "Basic YWRhOmNvcnJlY3Rob3JzZQ==",
"secret": "YWRhOmNvcnJlY3Rob3JzZQ"
},
{
"name": "Digest credentials",
"value": "Digest username=\"ada\", response=\"6629fae49393a05397450978507c4ef1\"",
"secret": "6629fae49393a05397450978507c4ef1"
},
{
"name": "Token credentials",
"value": "Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b",
"secret": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"
},
{
"name": "Authorization header",
"value": "Authorization: ApiKey Zq7Lm2Pw9Xr4Tn8Vb3Kd",
"secret": "Zq7Lm2Pw9Xr4Tn8Vb3Kd"
},
{
"name": "URL password",
"value": "https://ada:Tr0ub4dorPw@db.example.com/app",
"secret": "Tr0ub4dorPw"
},
{
"name": "Stripe webhook secret",
"value": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
"secret": "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
},
{
"name": "GitHub token",
"value": "ghp_16C7e42F292c6912E7710c838347Ae178B4a",
"secret": "16C7e42F292c6912E7710c838347Ae178B4a"
},
{
"name": "GitHub fine-grained token",
"value": "github_pat_11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJK",
"secret": "11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJK"
},
{
"name": "Slack token",
"value": "xoxb-2442-1234567890-AbCdEfGhIjKlMnOpQrStUvWx",
"secret": "2442-1234567890-AbCdEfGhIjKlMnOpQrStUvWx"
},
{
"name": "AWS access key id",
"value": "AKIAIOSFODNN7EXAMPLE",
"secret": "IOSFODNN7EXAMPLE"
},
{
"name": "Google API key",
"value": "AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY",
"secret": "SyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY"
},
{
"name": "OpenAI project key",
"value": "sk-proj-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789abcd",
"secret": "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789abcd"
},
{
"name": "Anthropic key",
"value": "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_abcd",
"secret": "api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_abcd"
},
{
"name": "npm token",
"value": "npm_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789",
"secret": "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"
},
{
"name": "Hugging Face token",
"value": "hf_AbCdEfGhIjKlMnOpQrStUvWxYz01234567",
"secret": "AbCdEfGhIjKlMnOpQrStUvWxYz01234567"
},
{
"name": "SendGrid key",
"value": "SG.AbCdEfGhIjKlMnOpQrStUv.WxYz0123456789AbCdEfGhIjKlMn",
"secret": "WxYz0123456789AbCdEfGhIjKlMn"
},
{
"name": "PEM private key",
"value": "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA7bq98fakeKeyMaterialLine\n-----END RSA PRIVATE KEY-----",
"secret": "MIIEowIBAAKCAQEA7bq98fakeKeyMaterialLine"
},
{
"name": "Sentry DSN",
"value": "https://0123456789abcdef0123456789abcdef@o1.ingest.sentry.io/42",
"secret": "0123456789abcdef0123456789abcdef"
}
],
"named": [
{
"key": "password",
"secret": "Tr0ub4dor-3"
},
{
"key": "newPassword",
"secret": "C0rrectHorse"
},
{
"key": "apiKey",
"secret": "k3y-AbC-998877"
},
{
"key": "client_secret",
"secret": "cs-Zz9Yy8Xx7"
},
{
"key": "access_token",
"secret": "at-Qq1Ww2Ee3"
},
{
"key": "refreshToken",
"secret": "rt-Pp0Oo9Ii8"
},
{
"key": "sessionId",
"secret": "sess-Mm4Nn5Bb6"
},
{
"key": "cardNumber",
"secret": "4111111111111111"
},
{
"key": "cvv",
"secret": "737-cvv"
},
{
"key": "otp",
"secret": "otp-482913"
},
{
"key": "privateKey",
"secret": "pk-Ll7Kk6Jj5"
},
{
"key": "signature",
"secret": "sig-Hh4Gg3Ff2"
}
]
}
203 changes: 203 additions & 0 deletions packages/devtools/src/__tests__/redaction-corpus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// @vitest-environment jsdom
/**
* The leak corpus: fake secrets in many shapes (fixtures/redaction-corpus.json)
* run through the redaction path of every collector and of the server, to
* prove none of them comes out readable. A new redaction path belongs in one
* of the tables below; a new secret shape belongs in the fixture.
*/
import { afterEach, describe, expect, it } from 'vitest';
import corpus from './fixtures/redaction-corpus.json' with { type: 'json' };
import {
REDACTED,
isRedactedKey,
redactMessage,
SecretSet,
setRedaction,
} from '../forms-privacy.ts';
import { redactRecord, redactText, redactUrl } from '../router.ts';
import { serialize } from '../serialize.ts';
import { redactCall, redactHeaders, redactPreview } from '../http-redact.ts';
import { redactJsonText } from '../json-text-redact.ts';
import { redactAnalogReport } from '../analog-redact.ts';
import { redactBody, redactMessage as redactServerMessage } from '../analog-server-log.ts';
import { maskedArgs } from '../capacitor-redact.ts';
import { serialize as ngrxSerialize } from '../ngrx-shared.ts';
import { consoleText, valueText } from '../errors-collector.ts';
import { serializeFormValue } from '../forms.ts';
import type { AnalogRuntimeReport } from '../analog-runtime.ts';
import type { HttpCall } from '../http-rules.ts';

afterEach(() => setRedaction());

type Path = (input: string) => unknown;
type ObjectPath = (input: Record<string, unknown>) => unknown;

const call = (fields: Partial<HttpCall>) =>
redactCall({ id: 1, method: 'GET', url: '/api', ...fields } as HttpCall);

const report = (fields: Partial<AnalogRuntimeReport>) =>
redactAnalogReport({
pageId: 'p1',
url: '/',
analog: true,
chain: [],
hydrated: 0,
transferState: false,
hydrationErrors: [],
configPaths: [],
...fields,
});

const show = (value: unknown): string =>
typeof value === 'string' ? value : (JSON.stringify(value) ?? '');

/** Paths that take free text: messages, errors, previews, values. */
const PAIR_TEXT_PATHS: Record<string, Path> = {
'serialize (component inputs, signals, pipes, providers)': (t) => serialize(t),
'NgRx serialize': (t) => ngrxSerialize(t),
'router redactText (titles, guards, warnings)': (t) => redactText(t),
'router redactRecord (params and data)': (t) => redactRecord({ note: t }),
'errors valueText': (t) => valueText(t),
'errors consoleText': (t) => consoleText(['failed:', t]),
'errors valueText of an Error': (t) => valueText(new Error(t)),
'HTTP call error': (t) => call({ error: t }),
'HTTP response preview (text)': (t) => redactPreview(t),
'HTTP header value': (t) => redactHeaders({ 'x-debug': t }),
'Analog hydration error': (t) => report({ hydrationErrors: [t] }),
'Capacitor plugin argument': (t) => maskedArgs('App', { message: t }, {}),
};

/** Paths that hide value shapes in text, but not `key=value` pairs. */
const SHAPE_ONLY_TEXT_PATHS: Record<string, Path> = {
// Object keys are masked by shape: a key is a name, not a sentence.
'serialize, as an object key': (t) => serialize({ [t]: 1 }),
'NgRx serialize, as an object key': (t) => ngrxSerialize({ [t]: 1 }),
'forms redactMessage (field errors)': (t) => redactMessage(t),
'forms SecretSet (event log)': (t) => new SecretSet().redact(t),
'Analog server log message': (t) => redactServerMessage(t),
'Analog server log body': (t) => redactBody(t),
};

/** Paths that take an object and hide its values by key. */
const OBJECT_PATHS: Record<string, ObjectPath> = {
serialize: (o) => serialize(o),
'NgRx serialize': (o) => ngrxSerialize(o),
'router redactRecord': (o) => redactRecord(o),
'forms serializeFormValue': (o) => serializeFormValue(o),
'HTTP response preview (JSON)': (o) => redactPreview(JSON.stringify(o)),
'HTTP response preview (cut JSON)': (o) => redactPreview(JSON.stringify(o).slice(0, -2)),
'HTTP call preview': (o) => call({ preview: JSON.stringify(o) }),
'JSON text redaction': (o) => redactJsonText(JSON.stringify(o), isRedactedKey),
'Capacitor plugin argument': (o) => maskedArgs('Preferences', o, {}),
'Analog load() preview': (o) =>
report({ load: { preview: JSON.stringify(o), bytes: 10, keys: [] } }),
'Analog server log body': (o) => redactBody(JSON.stringify(o)),
'errors valueText': (o) => valueText(o),
'errors consoleText': (o) => consoleText(['state', o]),
'HTTP headers': (o) =>
redactHeaders(Object.fromEntries(Object.entries(o).map(([k, v]) => [k, String(v)]))),
};

/** Paths that take a URL. */
const URL_PATHS: Record<string, Path> = {
'router redactUrl': (u) => redactUrl(u),
'HTTP call URL': (u) => call({ url: u }),
'HTTP redirect target': (u) => call({ redirectedTo: u }),
'Analog page URL': (u) => report({ url: u }),
'Capacitor HTTP URL': (u) => maskedArgs('CapacitorHttp', { url: u, method: 'GET' }, {}),
'Analog server log message': (u) => redactServerMessage(u),
};

function expectHidden(path: string, input: string, out: unknown, secret: string) {
const text = show(out);
expect(
text,
`${path} leaked ${JSON.stringify(secret)} from ${JSON.stringify(input)}`,
).not.toContain(secret);
expect(text, `${path} hid ${JSON.stringify(input)} without a marker`).toMatch(
/\[redacted\]|%5Bredacted%5D/,
);
}

describe('the leak corpus', () => {
it('has every kind of secret it promises', () => {
expect(corpus.shapes.length).toBeGreaterThanOrEqual(20);
expect(corpus.named.length).toBeGreaterThanOrEqual(10);
for (const entry of corpus.shapes) expect(entry.value).toContain(entry.secret);
});

describe('value shapes hide themselves in any text', () => {
const paths = { ...PAIR_TEXT_PATHS, ...SHAPE_ONLY_TEXT_PATHS };
for (const [path, run] of Object.entries(paths)) {
it(path, () => {
for (const { value, secret } of corpus.shapes) {
for (const input of [value, `request failed (${value}) retry later`]) {
expectHidden(path, input, run(input), secret);
}
}
});
}
});

describe('values under secret names hide in key=value text', () => {
for (const [path, run] of Object.entries(PAIR_TEXT_PATHS)) {
it(path, () => {
for (const { key, secret } of corpus.named) {
for (const input of [
`${key}=${secret}`,
`retry with ${key}=${secret}&user=ada`,
`{"${key}":"${secret}","user":"ada"}`,
`${key}: "${secret}"`,
]) {
expectHidden(path, input, run(input), secret);
}
}
});
}
});

describe('values under secret names hide in objects', () => {
for (const [path, run] of Object.entries(OBJECT_PATHS)) {
it(path, () => {
for (const { key, secret } of corpus.named) {
for (const input of [{ [key]: secret, user: 'ada' }, { form: { [key]: secret } }]) {
if (path === 'HTTP headers' && 'form' in input) continue;
expectHidden(path, JSON.stringify(input), run(input), secret);
}
}
});
}
});

describe('values under secret names hide in URLs', () => {
for (const [path, run] of Object.entries(URL_PATHS)) {
it(path, () => {
for (const { key, secret } of corpus.named) {
for (const input of [
`https://app.example.com/cb?${key}=${secret}&page=2`,
`/login#${key}=${encodeURIComponent(secret)}`,
]) {
expectHidden(path, input, run(input), secret);
}
}
});
}
});

describe('value shapes hide in URLs', () => {
const inUrl = corpus.shapes.filter((entry) => /^(https?:|eyJ)/.test(entry.value));
for (const [path, run] of Object.entries(URL_PATHS)) {
it(path, () => {
for (const { value, secret } of inUrl) {
const input = value.startsWith('eyJ') ? `/verify/${value}?page=2` : value;
expectHidden(path, input, run(input), secret);
}
});
}
});

it('keeps the marker every path writes the same', () => {
expect(REDACTED).toBe('[redacted]');
expect(redactJsonText('{"password":"x"}', isRedactedKey)).toBe(`{"password":"${REDACTED}"}`);
});
});
Loading
Loading