mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(core): Redact secrets rendered beside their label in browser snapshots (#36817)
This commit is contained in:
committed by
GitHub
parent
e3ec6795bf
commit
077d7782a2
@@ -1,4 +1,4 @@
|
||||
import { expandToTokenSpan } from '../token-span';
|
||||
import { assignmentNames, expandToTokenSpan, tokenize } from '../token-span';
|
||||
|
||||
function expand(text: string, match: string): string {
|
||||
return expandToTokenSpan(text, text.indexOf(match), match.length).span;
|
||||
@@ -80,3 +80,49 @@ describe('expandToTokenSpan', () => {
|
||||
expect(delimited(`${'x'.repeat(5000)}abc123`, 'abc123')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenize', () => {
|
||||
it.each([
|
||||
{
|
||||
named: 'strips punctuation around a value',
|
||||
text: '(abcdef1234567890),',
|
||||
want: ['abcdef1234567890'],
|
||||
},
|
||||
{ named: 'keeps a dot inside a token', text: 'AQ.Ab8RN6Jr7x', want: ['AQ.Ab8RN6Jr7x'] },
|
||||
{ named: 'never ends a token on a dot', text: 'value.', want: ['value'] },
|
||||
{
|
||||
named: 'keeps base64 padding',
|
||||
text: 'dGhpc2lzbm90YXJlYWw==',
|
||||
want: ['dGhpc2lzbm90YXJlYWw=='],
|
||||
},
|
||||
{
|
||||
named: 'breaks an assignment off its value',
|
||||
text: 'NAME=secretvalue',
|
||||
want: ['NAME=', 'secretvalue'],
|
||||
},
|
||||
{ named: 'drops empty runs', text: ' a b ', want: ['a', 'b'] },
|
||||
])('$named', ({ text, want }) => {
|
||||
expect(tokenize(text)).toEqual(want);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignmentNames', () => {
|
||||
it.each([
|
||||
{ named: 'a name separating a value', text: 'NAME=secretvalue', want: ['NAME='] },
|
||||
{ named: 'every name in a chain', text: 'a=b=c', want: ['a=', 'b='] },
|
||||
{ named: 'nothing for base64 padding', text: 'dGhpc2lzbm90YXJlYWw==', want: [] },
|
||||
{ named: 'nothing for padding followed by more text', text: 'dGhpcw== copy', want: [] },
|
||||
{ named: 'a name across spaces', text: 'NAME = secretvalue', want: ['NAME'] },
|
||||
{
|
||||
named: 'a name when only the value is spaced off',
|
||||
text: 'NAME =secretvalue',
|
||||
want: ['NAME', '='],
|
||||
},
|
||||
// Indistinguishable from padding plus a word at this level, so left alone
|
||||
// rather than guessed at — guessing would uncapture a real base64 secret.
|
||||
{ named: 'nothing when only the name is spaced off', text: 'NAME= secretvalue', want: [] },
|
||||
{ named: 'nothing when there is no assignment', text: 'plain text here', want: [] },
|
||||
])('reports $named', ({ text, want }) => {
|
||||
expect(assignmentNames(text)).toEqual(want);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface SecretHit {
|
||||
*/
|
||||
captureValue?: string;
|
||||
/** Why this hit must not become a credential, when it must not. */
|
||||
captureBlocked?: string;
|
||||
captureBlocked?: CaptureBlockedReason;
|
||||
}
|
||||
|
||||
type RedactionMarkerHit = Pick<SecretHit, 'type' | 'value' | 'ref'>;
|
||||
@@ -78,9 +78,19 @@ export function findRegexSecretHits(input: string): SecretHit[] {
|
||||
return [...hits.values()];
|
||||
}
|
||||
|
||||
// Each completes "<value> cannot be captured because …" in `browser_capture_secret`.
|
||||
export const UNDELIMITED_TOKEN = 'its surrounding text has no delimiter';
|
||||
|
||||
export const CONCATENATED_ONLY = 'it only appears where markup runs text together';
|
||||
export const PARTIAL_TOKEN = 'it is only part of a value the markup splits apart';
|
||||
export const ASSIGNMENT_NAME = 'it names the value rather than being it';
|
||||
|
||||
/** Why a hit may still be redacted but must never become a credential. */
|
||||
export type CaptureBlockedReason =
|
||||
| typeof UNDELIMITED_TOKEN
|
||||
| typeof CONCATENATED_ONLY
|
||||
| typeof PARTIAL_TOKEN
|
||||
| typeof ASSIGNMENT_NAME;
|
||||
|
||||
/** The value capturing would store, which is the whole token when one was found. */
|
||||
export function captureSpanOf(hit: SecretHit): string {
|
||||
|
||||
@@ -31,6 +31,45 @@ function endsTokenBefore(char: string): boolean {
|
||||
return endsToken(char) || STOPS_BEFORE.test(char);
|
||||
}
|
||||
|
||||
const STOP_RUN = new RegExp(`(?:${STOPS.source})+`);
|
||||
const EDGE_TRIM = new RegExp(`^(?:${EDGES.source})+|(?:${EDGES.source})+$`, 'g');
|
||||
// Zero-width, so padding stays on the token it belongs to: `=` ends a token only
|
||||
// when more token follows. This mirrors `endsTokenBefore`, which is the leftward
|
||||
// walk only — the rightward walk runs through `=` so base64 padding survives.
|
||||
const ASSIGNMENT = new RegExp(`(?<=${STOPS_BEFORE.source})(?!${STOPS_BEFORE.source})`);
|
||||
|
||||
/**
|
||||
* Split text into whole tokens on the same delimiters `expandToTokenSpan` snaps
|
||||
* to, so a value lifted straight out of the DOM is bounded like a matched one.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
return text
|
||||
.split(STOP_RUN)
|
||||
.flatMap((token) => (token.includes('=') ? token.split(ASSIGNMENT) : token))
|
||||
.map((token) => token.replace(EDGE_TRIM, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name sides of `NAME=value` pairs. They tokenize like any other run, so a
|
||||
* caller that reads tokens as values needs to know which ones never are. Base64
|
||||
* padding is not one: its `=` ends the run instead of separating two.
|
||||
*/
|
||||
export function assignmentNames(text: string): string[] {
|
||||
const names: string[] = [];
|
||||
const runs = text.split(STOP_RUN);
|
||||
runs.forEach((run, index) => {
|
||||
// A run *starting* with `=` is a separator — padding can only end one — so
|
||||
// the run before it is a name however the whitespace fell. `NAME= value` is
|
||||
// deliberately not covered: at this level it is `dGhpcw== copy` exactly.
|
||||
if (runs[index + 1]?.startsWith('=')) names.push(run.replace(EDGE_TRIM, ''));
|
||||
if (!run.includes('=')) return;
|
||||
const parts = run.split(ASSIGNMENT).filter(Boolean);
|
||||
for (const name of parts.slice(0, -1)) names.push(name.replace(EDGE_TRIM, ''));
|
||||
});
|
||||
return names.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a match out to the whole token around it, so an unanticipated prefix or
|
||||
* suffix is covered without enumerating character classes. Only trims outside
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { analyzeHtmlSensitivity } from './analyze-html';
|
||||
import type { HtmlProbeResult } from '../types';
|
||||
import { ASSIGNMENT_NAME, CONCATENATED_ONLY, PARTIAL_TOKEN } from '../redaction/redact';
|
||||
import { htmlProbe as probe } from '../tools/test-helpers';
|
||||
|
||||
const ANTHROPIC = `sk-ant-api03-${'a'.repeat(93)}AA`;
|
||||
const OPAQUE = 'notreal-IMzLaCKsU6ZxAbt2qFc9XYdRpQ7vNtBmKL';
|
||||
|
||||
function probe(
|
||||
html: string,
|
||||
children = [] as NonNullable<HtmlProbeResult['root']>['children'],
|
||||
): HtmlProbeResult {
|
||||
return {
|
||||
ok: true,
|
||||
root: {
|
||||
kind: 'document',
|
||||
html,
|
||||
url: 'http://test.com',
|
||||
children,
|
||||
errors: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
const HEX_SECRET = 'notreal7c1de9a04bf28e6d3a91f0b5c7e2d84a6';
|
||||
const DOTTED_SECRET = 'AQI.notrealuhfuehfiaSkdjLmQpWoEiRuTyZxCvBnMaGh';
|
||||
|
||||
describe('analyzeHtmlSensitivity', () => {
|
||||
// The same key often appears in the page and again in an embedded frame; the
|
||||
@@ -132,7 +119,7 @@ describe('analyzeHtmlSensitivity', () => {
|
||||
});
|
||||
|
||||
it('finds a secret held in a data-* attribute behind a placeholder value', () => {
|
||||
const secret = 'notreal7c1de9a04bf28e6d3a91f0b5c7e2d84a6';
|
||||
const secret = HEX_SECRET;
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(
|
||||
`<label for="c">Client Secret</label><input id="c" type="password" readonly value="1234567890" data-password="${secret}" data-qa="client_secret">`,
|
||||
@@ -161,7 +148,7 @@ describe('analyzeHtmlSensitivity', () => {
|
||||
});
|
||||
|
||||
it('harvests only data-* attributes whose name reads as a secret', () => {
|
||||
const secret = 'notreal7c1de9a04bf28e6d3a91f0b5c7e2d84a6';
|
||||
const secret = HEX_SECRET;
|
||||
const tracking = 'trackingId0123456789abcdef';
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(
|
||||
@@ -256,6 +243,153 @@ describe('analyzeHtmlSensitivity', () => {
|
||||
expect(result.ok && result.hits).toEqual([]);
|
||||
});
|
||||
|
||||
// A console issues a credential as static text beside its label, with no input
|
||||
// to key off. These shapes clear no entropy bar, so the label is the evidence.
|
||||
it.each([
|
||||
{
|
||||
named: 'a dt label',
|
||||
value: HEX_SECRET,
|
||||
html: `<dl><dt>Client Secret</dt><dd>${HEX_SECRET}</dd></dl>`,
|
||||
},
|
||||
{
|
||||
named: 'its own test id, below the entropy bar the test-id pass applies',
|
||||
value: HEX_SECRET,
|
||||
html: `<dl><dt>Issued</dt><dd data-testid="client-secret">${HEX_SECRET}</dd></dl>`,
|
||||
},
|
||||
{
|
||||
named: 'a two-word label ending in a different credential noun',
|
||||
value: HEX_SECRET,
|
||||
html: `<dl><dt>Secret Key</dt><dd>${HEX_SECRET}</dd></dl>`,
|
||||
},
|
||||
{
|
||||
named: 'a label, sharing the cell with a copy button',
|
||||
value: DOTTED_SECRET,
|
||||
html: `<dl><dt>Client Secret</dt><dd>${DOTTED_SECRET} <button>Copy</button></dd></dl>`,
|
||||
},
|
||||
])('finds a static value named as a secret by $named', ({ html, value }) => {
|
||||
const result = analyzeHtmlSensitivity(probe(html));
|
||||
|
||||
expect(result.ok && result.hits).toContainEqual({ type: 'password', value });
|
||||
});
|
||||
|
||||
// Neither spelling of a split value may become a credential: the joined form
|
||||
// appears nowhere in what the model reads, and the rendered form is a fragment
|
||||
// of it. Asserted exactly — `toContainEqual` would hide the second hit.
|
||||
it('blocks capture of a secret split across inline elements', () => {
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(
|
||||
'<dl><dt>Client Secret</dt><dd><span>AQI.</span>' +
|
||||
`<span>${DOTTED_SECRET.slice(4)}</span></dd></dl>`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.ok && result.hits).toEqual([
|
||||
{ type: 'password', value: DOTTED_SECRET.slice(4), captureBlocked: PARTIAL_TOKEN },
|
||||
{ type: 'password', value: DOTTED_SECRET, captureBlocked: CONCATENATED_ONLY },
|
||||
]);
|
||||
});
|
||||
|
||||
// Inline children run together into a token that exists nowhere on the page.
|
||||
it('blocks capture of a token only the concatenated markup produces', () => {
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(
|
||||
'<dl><dt>Client Secret</dt><dd><span>Rotated</span>' +
|
||||
'<span>quarterly</span><span>by ops</span></dd></dl>',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.ok && result.hits).toEqual([
|
||||
{ type: 'password', value: 'Rotatedquarterlyby', captureBlocked: CONCATENATED_ONLY },
|
||||
]);
|
||||
});
|
||||
|
||||
// A field name long enough to clear the length floor is masked with the value,
|
||||
// but capturing it would store the name as the credential. The name must be
|
||||
// long enough here or the length floor hides the rule being tested.
|
||||
it('blocks capture of the name side of an assignment', () => {
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(`<dl><dt>Client Secret</dt><dd>GOOGLE_CLIENT_SECRET=${HEX_SECRET}</dd></dl>`),
|
||||
);
|
||||
|
||||
expect(result.ok && result.hits).toEqual([
|
||||
{ type: 'password', value: 'GOOGLE_CLIENT_SECRET=', captureBlocked: ASSIGNMENT_NAME },
|
||||
{ type: 'password', value: HEX_SECRET },
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks capture of an assignment name separated by whitespace', () => {
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(`<dl><dt>Client Secret</dt><dd>GOOGLE_CLIENT_SECRET = ${HEX_SECRET}</dd></dl>`),
|
||||
);
|
||||
|
||||
expect(result.ok && result.hits).toEqual([
|
||||
{ type: 'password', value: 'GOOGLE_CLIENT_SECRET', captureBlocked: ASSIGNMENT_NAME },
|
||||
{ type: 'password', value: HEX_SECRET },
|
||||
]);
|
||||
});
|
||||
|
||||
// Base64 padding also ends on `=` but separates nothing, so it stays capturable.
|
||||
it('still captures a padded base64 value sharing the cell with other text', () => {
|
||||
const value = 'dGhpc2lzbm90YXJlYWxzZWNyZXQ==';
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(`<dl><dt>Client Secret</dt><dd>${value} copy</dd></dl>`),
|
||||
);
|
||||
|
||||
expect(result.ok && result.hits).toEqual([{ type: 'password', value }]);
|
||||
});
|
||||
|
||||
// The credential flow needs the public identifier that sits beside the
|
||||
// secret; redacting the whole block would break the thing this protects.
|
||||
it('does not flag the public identifier beside a secret in the same list', () => {
|
||||
const clientId = '553213193971.11823370532599';
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(
|
||||
`<dl><dt>Client ID</dt><dd>${clientId}</dd>` +
|
||||
`<dt>Client Secret</dt><dd>${HEX_SECRET}</dd></dl>`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.ok && result.hits).toEqual([{ type: 'password', value: HEX_SECRET }]);
|
||||
});
|
||||
|
||||
// A credential noun trailed by a qualifier describes the credential rather
|
||||
// than being it, and a commit SHA is entropy-identical to a hex secret — so
|
||||
// only the label can tell them apart. Masks carry nothing to leak.
|
||||
it.each([
|
||||
{
|
||||
named: 'a qualifier turns the label into a description (timestamp)',
|
||||
html: '<dl><dt>Token expiry</dt><dd>Expires 2026-01-01T00:00:00.000Z</dd></dl>',
|
||||
},
|
||||
{
|
||||
named: 'a qualifier turns the label into a description (docs link)',
|
||||
html: '<dl><dt>API key docs</dt><dd>https://docs.example.com/api-keys</dd></dl>',
|
||||
},
|
||||
{
|
||||
named: 'a qualifier turns the label into a description (commit sha)',
|
||||
html: '<dl><dt>Token commit</dt><dd>9f4e2a1c8b7d6e5f0a3b2c1d4e5f6a7b8c9d0e1f</dd></dl>',
|
||||
},
|
||||
{
|
||||
named: 'the value is a mask rather than a secret',
|
||||
html: '<dl><dt>Client Secret</dt><dd>••••••••••••••••••••</dd></dl>',
|
||||
},
|
||||
])('does not flag a cell where $named', ({ html }) => {
|
||||
const result = analyzeHtmlSensitivity(probe(html));
|
||||
|
||||
expect(result.ok && result.hits).toEqual([]);
|
||||
});
|
||||
|
||||
// One reject case stays at pipeline level: it is the only proof that the label
|
||||
// sources are judged separately rather than joined.
|
||||
it('does not treat an id qualified by another attribute as naming a secret', () => {
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(
|
||||
'<dl><dt>Issued</dt><dd id="token-expiry" data-testid="row">2026-01-01T00:00:00.000Z</dd></dl>',
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.ok && result.hits).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds high-entropy values in reveal dialogs', () => {
|
||||
const result = analyzeHtmlSensitivity(
|
||||
probe(`<div role="dialog"><p>You won't see it again.</p><code>${OPAQUE}</code></div>`),
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
hasButtonMatching,
|
||||
highEntropyCandidates,
|
||||
isSensitiveInput,
|
||||
isSecretLabelledCell,
|
||||
opaqueTokenCandidates,
|
||||
getLabelTextByControlIdMap,
|
||||
REVEAL_BUTTON_PATTERN,
|
||||
REVEAL_PHRASE_PATTERNS,
|
||||
@@ -66,6 +68,14 @@ function analyzeDocument(html: string, hits: Map<string, SecretHit>): void {
|
||||
}
|
||||
}
|
||||
|
||||
// A console renders an issued credential as static text beside its label, with
|
||||
// no input to key off. A conservative first cut: div-soup rows, a second `dd`
|
||||
// under one `dt`, and `thead` column headers are all still uncovered.
|
||||
for (const cell of Array.from(document.querySelectorAll('dd, td'))) {
|
||||
if (!isSecretLabelledCell(cell)) continue;
|
||||
for (const hit of opaqueTokenCandidates(cell)) collectHit(hits, hit);
|
||||
}
|
||||
|
||||
// Reveal dialogs are the high-risk flow: newly created credentials are often
|
||||
// rendered once with copy affordances and explanatory text.
|
||||
for (const dialog of Array.from(document.querySelectorAll('[role="dialog"], dialog[open]'))) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import {
|
||||
COPY_BUTTON_PATTERN,
|
||||
REVEAL_BUTTON_PATTERN,
|
||||
@@ -6,6 +8,8 @@ import {
|
||||
SENSITIVE_FIELD_LABEL_PATTERN,
|
||||
SENSITIVE_TESTID_PATTERN,
|
||||
highEntropyCandidates,
|
||||
isSecretLabelledCell,
|
||||
opaqueTokenCandidates,
|
||||
shannonEntropy,
|
||||
} from './dom-matchers';
|
||||
|
||||
@@ -216,3 +220,67 @@ describe('REVEAL_PHRASE_PATTERNS', () => {
|
||||
expect(matchesAny(phrase)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** The value cell of a label/value fixture, which is always the last one. */
|
||||
function cell(html: string): Element {
|
||||
const cells = new JSDOM(`<dl>${html}</dl>`).window.document.querySelectorAll('dd, td');
|
||||
return cells[cells.length - 1];
|
||||
}
|
||||
|
||||
describe('isSecretLabelledCell', () => {
|
||||
it.each([
|
||||
{ named: 'a dt partner naming a secret', html: '<dt>Client Secret</dt><dd>v</dd>' },
|
||||
{
|
||||
named: 'a th partner naming a secret',
|
||||
html: '<table><tr><th>API Key</th><td>v</td></tr></table>',
|
||||
},
|
||||
{
|
||||
named: 'a td partner naming a secret',
|
||||
html: '<table><tr><td>Client Secret</td><td>v</td></tr></table>',
|
||||
},
|
||||
{ named: 'a decorated label', html: '<dt>Client Secret:</dt><dd>v</dd>' },
|
||||
{ named: 'the cell id', html: '<dt>Issued</dt><dd id="client-secret">v</dd>' },
|
||||
{ named: 'the cell test id', html: '<dt>Issued</dt><dd data-testid="client-secret">v</dd>' },
|
||||
{ named: 'a camelCase test id', html: '<dt>Issued</dt><dd data-testid="clientSecret">v</dd>' },
|
||||
{ named: 'a camelCase id', html: '<dt>Issued</dt><dd id="apiKeyValue">v</dd>' },
|
||||
])('accepts $named', ({ html }) => {
|
||||
expect(isSecretLabelledCell(cell(html))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ named: 'a label the noun only qualifies', html: '<dt>Token expiry</dt><dd>v</dd>' },
|
||||
{ named: 'a qualified label behind decoration', html: '<dt>Credential type:</dt><dd>v</dd>' },
|
||||
{
|
||||
named: 'a camelCase identifier merely containing a noun',
|
||||
html: '<dt>Issued</dt><dd id="tokenizerOutput">v</dd>',
|
||||
},
|
||||
{
|
||||
named: 'a camelCase test id merely containing a noun',
|
||||
html: '<dt>Issued</dt><dd data-testid="secretaryPanel">v</dd>',
|
||||
},
|
||||
{
|
||||
named: 'an identifier merely containing a noun',
|
||||
html: '<dt>Issued</dt><dd id="tokenizer-output">v</dd>',
|
||||
},
|
||||
{ named: 'an unrelated label', html: '<dt>Client ID</dt><dd>v</dd>' },
|
||||
{ named: 'no label at all', html: '<dd>v</dd>' },
|
||||
])('rejects $named', ({ html }) => {
|
||||
expect(isSecretLabelledCell(cell(html))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opaqueTokenCandidates', () => {
|
||||
it('takes a long unbroken run whatever its charset', () => {
|
||||
expect(opaqueTokenCandidates(cell('<dd>f8c1b2d47e6a903b5c4d1e8f2a7b6c95</dd>'))).toEqual([
|
||||
{ type: 'password', value: 'f8c1b2d47e6a903b5c4d1e8f2a7b6c95' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ named: 'prose', html: '<dd>Every 90 days</dd>' },
|
||||
{ named: 'a value under the length floor', html: '<dd>OAuth2</dd>' },
|
||||
{ named: 'a mask', html: '<dd>••••••••••••••••••••</dd>' },
|
||||
])('ignores $named', ({ html }) => {
|
||||
expect(opaqueTokenCandidates(cell(html))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
import { collectHit, UNDELIMITED_TOKEN, type SecretHit } from '../redaction/redact';
|
||||
import { expandToTokenSpan } from '../redaction/token-span';
|
||||
import {
|
||||
ASSIGNMENT_NAME,
|
||||
type CaptureBlockedReason,
|
||||
collectHit,
|
||||
CONCATENATED_ONLY,
|
||||
PARTIAL_TOKEN,
|
||||
UNDELIMITED_TOKEN,
|
||||
type SecretHit,
|
||||
} from '../redaction/redact';
|
||||
import { assignmentNames, expandToTokenSpan, tokenize } from '../redaction/token-span';
|
||||
|
||||
export const TESTID_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa'] as const;
|
||||
|
||||
export const SENSITIVE_TESTID_PATTERN =
|
||||
/(^|[-_\s])(api[-_\s]?key|apikey|admin[-_\s]?key|access[-_\s]?token|auth[-_\s]?token|session[-_\s]?token|secret|credential|password|key)([-_\s]|$)/i;
|
||||
// An identifier is not prose: `tokenizer-output` and `secretary-panel` carry a
|
||||
// credential noun without naming one, so a vocabulary only counts at separators.
|
||||
const identifierBoundary = (vocabulary: RegExp) =>
|
||||
new RegExp(`(^|[-_\\s])(?:${vocabulary.source})([-_\\s]|$)`, 'i');
|
||||
|
||||
// `clientSecret` names a secret as plainly as `client-secret` does, so give the
|
||||
// hump the separator the boundary rule needs. Humpless runs (`tokenizer`) are
|
||||
// untouched, which is what keeps them out.
|
||||
const separateCamelHumps = (text: string) => text.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
|
||||
|
||||
/** Shortest run we treat as opaque rather than prose. */
|
||||
const MIN_OPAQUE_TOKEN_LENGTH = 16;
|
||||
|
||||
// Only excludes degenerate runs: a mask of repeated characters scores 0, while
|
||||
// the flattest real secret shape (hex) still scores ~3.95.
|
||||
const MIN_OPAQUE_TOKEN_ENTROPY = 2;
|
||||
|
||||
// A credential noun trailed by one of these describes the credential instead of
|
||||
// being it: "Token expiry", "API key docs". Deliberately a denylist rather than
|
||||
// requiring the noun to end the label: an unknown qualifier here over-redacts,
|
||||
// whereas an unknown head noun ("Client secret value") would leak.
|
||||
const QUALIFIED_LABEL_PATTERN =
|
||||
/\b(expir\w*|type|kind|status|state|policy|docs?|documentation|guide|help|name|id|created|updated|modified|date|time|commit|version|count|rotation|scopes?|fingerprint|hint|prefix|suffix|owner|author)\s*$/i;
|
||||
|
||||
const TESTID_VOCABULARY =
|
||||
/api[-_\s]?key|apikey|admin[-_\s]?key|access[-_\s]?token|auth[-_\s]?token|session[-_\s]?token|secret|credential|password|key/;
|
||||
|
||||
export const SENSITIVE_TESTID_PATTERN = identifierBoundary(TESTID_VOCABULARY);
|
||||
|
||||
export const SENSITIVE_ARIA_LABEL_PATTERN =
|
||||
/(api[-_\s]?key|secret[-_\s]?key|access[-_\s]?token|auth[-_\s]?token|client[-_\s]?secret|password|credential)/i;
|
||||
@@ -83,6 +117,51 @@ export function getAssociatedLabelText(
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELD_ATTR_PATTERN = identifierBoundary(SENSITIVE_FIELD_LABEL_PATTERN);
|
||||
|
||||
// A trailing colon, asterisk or parenthetical is decoration, not the end of the
|
||||
// label — without stripping it the qualifier rule below never anchors.
|
||||
const LABEL_DECORATION = /(?:\s*(?:\([^()]*\)|\[[^\]]*\]|[:*.,;·—-]))+$/;
|
||||
|
||||
// A label is a few words; longer text only landed in the cell by accident, and
|
||||
// `LABEL_DECORATION` backtracks quadratically over it. Skipping the strip there
|
||||
// leaves the vocabulary test to run on undecorated text, which at worst
|
||||
// over-redacts — the safe direction.
|
||||
const MAX_LABEL_LENGTH = 200;
|
||||
|
||||
function labelEnd(text: string): string {
|
||||
if (!text) return '';
|
||||
const label = text.replace(/\s+/g, ' ').trim();
|
||||
return label.length > MAX_LABEL_LENGTH ? label : label.replace(LABEL_DECORATION, '');
|
||||
}
|
||||
|
||||
function namesSecret(text: string, pattern: RegExp): boolean {
|
||||
const label = labelEnd(text);
|
||||
return pattern.test(label) && !QUALIFIED_LABEL_PATTERN.test(label);
|
||||
}
|
||||
|
||||
// A row may label its value with a plain `td` rather than a `th`; the label
|
||||
// vocabulary, not the tag, is what qualifies it.
|
||||
const LABEL_PARTNER_TAGS = ['DT', 'TH', 'TD'];
|
||||
|
||||
/**
|
||||
* Whether a static value cell is named as holding a secret — by its label
|
||||
* partner or by its own attributes. Each source is judged separately, or the
|
||||
* trailing-qualifier rule would anchor to whichever source happened to be last.
|
||||
*/
|
||||
export function isSecretLabelledCell(el: Element): boolean {
|
||||
const prev = el.previousElementSibling;
|
||||
const partner = prev && LABEL_PARTNER_TAGS.includes(prev.tagName) ? prev.textContent : '';
|
||||
if (namesSecret(partner ?? '', SENSITIVE_FIELD_LABEL_PATTERN)) return true;
|
||||
// Five `getAttribute` calls per cell otherwise, on pages where most carry none.
|
||||
if (!el.hasAttributes()) return false;
|
||||
return (
|
||||
namesSecret(separateCamelHumps(el.getAttribute('id') ?? ''), SENSITIVE_FIELD_ATTR_PATTERN) ||
|
||||
// The test-id pass judges the same attribute, so it must agree with it.
|
||||
namesSecret(separateCamelHumps(getTestId(el)), SENSITIVE_TESTID_PATTERN)
|
||||
);
|
||||
}
|
||||
|
||||
export function elementText(el: Element): string {
|
||||
const parts: string[] = [];
|
||||
for (const node of Array.from(el.childNodes)) {
|
||||
@@ -122,7 +201,11 @@ export function sensitiveInputValues(el: Element): string[] {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (!attr.name.startsWith('data-') || !SENSITIVE_TESTID_PATTERN.test(attr.name)) continue;
|
||||
const candidate = attr.value.trim();
|
||||
if (candidate.length >= 16 && !/\s/.test(candidate) && !values.includes(candidate)) {
|
||||
if (
|
||||
candidate.length >= MIN_OPAQUE_TOKEN_LENGTH &&
|
||||
!/\s/.test(candidate) &&
|
||||
!values.includes(candidate)
|
||||
) {
|
||||
values.push(candidate);
|
||||
}
|
||||
}
|
||||
@@ -149,6 +232,50 @@ export function shannonEntropy(value: string): number {
|
||||
return entropy;
|
||||
}
|
||||
|
||||
function opaqueTokens(text: string): string[] {
|
||||
return tokenize(text).filter(
|
||||
(token) =>
|
||||
token.length >= MIN_OPAQUE_TOKEN_LENGTH && shannonEntropy(token) >= MIN_OPAQUE_TOKEN_ENTROPY,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque tokens from a container its label already confirmed, so length carries
|
||||
* the decision that entropy carries over a region.
|
||||
*/
|
||||
export function opaqueTokenCandidates(el: Element): SecretHit[] {
|
||||
const text = elementText(el);
|
||||
const rendered = opaqueTokens(text);
|
||||
// `elementText` spaces inline children apart, so a value split across them is
|
||||
// whole only in `textContent` — a spelling that appears nowhere in what the
|
||||
// model reads, which is why neither it nor the rendered fragments inside it
|
||||
// may become a credential.
|
||||
const seen = new Set(rendered);
|
||||
const concatenated = el.firstElementChild
|
||||
? opaqueTokens(el.textContent ?? '').filter((value) => !seen.has(value))
|
||||
: [];
|
||||
const names = new Set(assignmentNames(text));
|
||||
|
||||
// Masked either way; the reason decides only whether it may be captured.
|
||||
const blocked = (value: string): CaptureBlockedReason | undefined => {
|
||||
if (names.has(value)) return ASSIGNMENT_NAME;
|
||||
if (concatenated.some((whole) => whole.includes(value))) return PARTIAL_TOKEN;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return [
|
||||
...rendered.map((value): SecretHit => {
|
||||
const captureBlocked = blocked(value);
|
||||
return captureBlocked
|
||||
? { type: 'password', value, captureBlocked }
|
||||
: { type: 'password', value };
|
||||
}),
|
||||
...concatenated.map(
|
||||
(value): SecretHit => ({ type: 'password', value, captureBlocked: CONCATENATED_ONLY }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Scored on the inner match, reported as the whole token: a shape this class
|
||||
// misses must not be split into fragments.
|
||||
export function highEntropyCandidates(text: string): SecretHit[] {
|
||||
|
||||
Reference in New Issue
Block a user