diff --git a/packages/@n8n/mcp-browser/src/redaction/__tests__/token-span.test.ts b/packages/@n8n/mcp-browser/src/redaction/__tests__/token-span.test.ts index 06fff176b75..779636985cf 100644 --- a/packages/@n8n/mcp-browser/src/redaction/__tests__/token-span.test.ts +++ b/packages/@n8n/mcp-browser/src/redaction/__tests__/token-span.test.ts @@ -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); + }); +}); diff --git a/packages/@n8n/mcp-browser/src/redaction/redact.ts b/packages/@n8n/mcp-browser/src/redaction/redact.ts index 5f5986d5eee..5608747cc8b 100644 --- a/packages/@n8n/mcp-browser/src/redaction/redact.ts +++ b/packages/@n8n/mcp-browser/src/redaction/redact.ts @@ -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; @@ -78,9 +78,19 @@ export function findRegexSecretHits(input: string): SecretHit[] { return [...hits.values()]; } +// Each completes " 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 { diff --git a/packages/@n8n/mcp-browser/src/redaction/token-span.ts b/packages/@n8n/mcp-browser/src/redaction/token-span.ts index e5986f3ffe9..f6a7258d7c8 100644 --- a/packages/@n8n/mcp-browser/src/redaction/token-span.ts +++ b/packages/@n8n/mcp-browser/src/redaction/token-span.ts @@ -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 diff --git a/packages/@n8n/mcp-browser/src/sensitivity/analyze-html.test.ts b/packages/@n8n/mcp-browser/src/sensitivity/analyze-html.test.ts index 6e806951374..83767febe5f 100644 --- a/packages/@n8n/mcp-browser/src/sensitivity/analyze-html.test.ts +++ b/packages/@n8n/mcp-browser/src/sensitivity/analyze-html.test.ts @@ -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['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( ``, @@ -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: `
Client Secret
${HEX_SECRET}
`, + }, + { + named: 'its own test id, below the entropy bar the test-id pass applies', + value: HEX_SECRET, + html: `
Issued
${HEX_SECRET}
`, + }, + { + named: 'a two-word label ending in a different credential noun', + value: HEX_SECRET, + html: `
Secret Key
${HEX_SECRET}
`, + }, + { + named: 'a label, sharing the cell with a copy button', + value: DOTTED_SECRET, + html: `
Client Secret
${DOTTED_SECRET}
`, + }, + ])('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( + '
Client Secret
AQI.' + + `${DOTTED_SECRET.slice(4)}
`, + ), + ); + + 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( + '
Client Secret
Rotated' + + 'quarterlyby ops
', + ), + ); + + 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(`
Client Secret
GOOGLE_CLIENT_SECRET=${HEX_SECRET}
`), + ); + + 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(`
Client Secret
GOOGLE_CLIENT_SECRET = ${HEX_SECRET}
`), + ); + + 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(`
Client Secret
${value} copy
`), + ); + + 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( + `
Client ID
${clientId}
` + + `
Client Secret
${HEX_SECRET}
`, + ), + ); + + 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: '
Token expiry
Expires 2026-01-01T00:00:00.000Z
', + }, + { + named: 'a qualifier turns the label into a description (docs link)', + html: '
API key docs
https://docs.example.com/api-keys
', + }, + { + named: 'a qualifier turns the label into a description (commit sha)', + html: '
Token commit
9f4e2a1c8b7d6e5f0a3b2c1d4e5f6a7b8c9d0e1f
', + }, + { + named: 'the value is a mask rather than a secret', + html: '
Client Secret
••••••••••••••••••••
', + }, + ])('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( + '
Issued
2026-01-01T00:00:00.000Z
', + ), + ); + + expect(result.ok && result.hits).toEqual([]); + }); + it('finds high-entropy values in reveal dialogs', () => { const result = analyzeHtmlSensitivity( probe(`

You won't see it again.

${OPAQUE}
`), diff --git a/packages/@n8n/mcp-browser/src/sensitivity/analyze-html.ts b/packages/@n8n/mcp-browser/src/sensitivity/analyze-html.ts index 122a08adc9d..75618932bdb 100644 --- a/packages/@n8n/mcp-browser/src/sensitivity/analyze-html.ts +++ b/packages/@n8n/mcp-browser/src/sensitivity/analyze-html.ts @@ -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): 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]'))) { diff --git a/packages/@n8n/mcp-browser/src/sensitivity/dom-matchers.test.ts b/packages/@n8n/mcp-browser/src/sensitivity/dom-matchers.test.ts index 7650bc931f8..64c3455101e 100644 --- a/packages/@n8n/mcp-browser/src/sensitivity/dom-matchers.test.ts +++ b/packages/@n8n/mcp-browser/src/sensitivity/dom-matchers.test.ts @@ -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(`
${html}
`).window.document.querySelectorAll('dd, td'); + return cells[cells.length - 1]; +} + +describe('isSecretLabelledCell', () => { + it.each([ + { named: 'a dt partner naming a secret', html: '
Client Secret
v
' }, + { + named: 'a th partner naming a secret', + html: '
API Keyv
', + }, + { + named: 'a td partner naming a secret', + html: '
Client Secretv
', + }, + { named: 'a decorated label', html: '
Client Secret:
v
' }, + { named: 'the cell id', html: '
Issued
v
' }, + { named: 'the cell test id', html: '
Issued
v
' }, + { named: 'a camelCase test id', html: '
Issued
v
' }, + { named: 'a camelCase id', html: '
Issued
v
' }, + ])('accepts $named', ({ html }) => { + expect(isSecretLabelledCell(cell(html))).toBe(true); + }); + + it.each([ + { named: 'a label the noun only qualifies', html: '
Token expiry
v
' }, + { named: 'a qualified label behind decoration', html: '
Credential type:
v
' }, + { + named: 'a camelCase identifier merely containing a noun', + html: '
Issued
v
', + }, + { + named: 'a camelCase test id merely containing a noun', + html: '
Issued
v
', + }, + { + named: 'an identifier merely containing a noun', + html: '
Issued
v
', + }, + { named: 'an unrelated label', html: '
Client ID
v
' }, + { named: 'no label at all', html: '
v
' }, + ])('rejects $named', ({ html }) => { + expect(isSecretLabelledCell(cell(html))).toBe(false); + }); +}); + +describe('opaqueTokenCandidates', () => { + it('takes a long unbroken run whatever its charset', () => { + expect(opaqueTokenCandidates(cell('
f8c1b2d47e6a903b5c4d1e8f2a7b6c95
'))).toEqual([ + { type: 'password', value: 'f8c1b2d47e6a903b5c4d1e8f2a7b6c95' }, + ]); + }); + + it.each([ + { named: 'prose', html: '
Every 90 days
' }, + { named: 'a value under the length floor', html: '
OAuth2
' }, + { named: 'a mask', html: '
••••••••••••••••••••
' }, + ])('ignores $named', ({ html }) => { + expect(opaqueTokenCandidates(cell(html))).toEqual([]); + }); +}); diff --git a/packages/@n8n/mcp-browser/src/sensitivity/dom-matchers.ts b/packages/@n8n/mcp-browser/src/sensitivity/dom-matchers.ts index af36ee20aa5..2498758d67a 100644 --- a/packages/@n8n/mcp-browser/src/sensitivity/dom-matchers.ts +++ b/packages/@n8n/mcp-browser/src/sensitivity/dom-matchers.ts @@ -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[] {