fix(core): Redact secrets rendered beside their label in browser snapshots (#36817)

This commit is contained in:
Bernhard Wittmann
2026-08-21 13:24:00 +00:00
committed by GitHub
parent e3ec6795bf
commit 077d7782a2
7 changed files with 460 additions and 26 deletions
@@ -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[] {