You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
v3.0.11 - Default redaction for secrets present at page load; known-sensitive URLs suppressed outright
This commit was created on GitHub.com and signed with GitHub’s verified signature.
[3.0.11] - 2026-10-06 - Default redaction for secrets present at page load; known-sensitive URLs suppressed outright
Security
All of #55/#59's redaction depends on a field having a recognized type/autocomplete, or having already mirrored a typed value somewhere -- neither helps a secret that is already in the DOM the moment the page loads, before any action (and so before any data-sen-secret marking, which can only happen AFTER an action reveals a value) has run at all. Real case: Slack's 2FA setup page (/account/settings/2fa_app) renders the TOTP seed into a hidden #init_key_code element from page load, so the very first auto-capture of that page wrote the raw seed to disk.
Default secret-pattern detection: an element whose id/name/class/autocomplete/aria-label contains a secret-looking WORD (secret, totp, otp, 2fa, mfa, key_code, seed, recovery, backup_code, api_key, token) is redacted on first sight, no marking or click required. Matched on word/segment boundaries (split on -, _, camelCase and whitespace), not a raw substring: otp no longer matches inside footprint, and token no longer matches inside a token-list/tokens-table id. A matched value-bearing field (input/textarea/select) is treated exactly like any other sensitive field. A matched LEAF element (no element children of its own) has its own text content blanked directly by element reference, regardless of length -- NOT via a page-wide substring replace: an earlier version of this collected that text into the same redaction set used for typed field values, which corrupted unrelated page content sharing the same word (a Prism.js <span class="token keyword">class</span> turned every class="..." attribute on the page into [REDACTED]="..."). A matched CONTAINER (has element children, e.g. <div id="totp-secret">) is fully blanked -- every direct text-node child, and every non-control descendant recursively, is redacted -- fixing a real leak where a secret split across child elements (<div id="totp-secret">Key: <code>SEED</code></div>, a <ul class="recovery-codes"><li> list) was written to disk in clear because the container itself has element children and was skipped outright -- but only when the match is a strong, exact compound (totp-secret, secret-key, recovery-codes, backup-codes, api-key, private-key, key-code, ...) and the container's text is 2000 characters or fewer. A WEAK match (a single broad word, e.g. a design-system class sn-token-provider or a docs landmark <section id="module-secrets">) does NOT wholesale-blank its container -- an earlier version of this fix did, which wiped ordinary docs/design-system page content with no secret in it; a weak container's own attributes are still stripped and any descendant that independently matches is still found and blanked on its own. Button/link elements (and anything nested inside one) are never text-blanked, since a control's own visible text is a UI label, not the secret. (label/summary/legend/option lost this exemption in round 4 -- see below.) The .md artifact gets the identical word-boundary redaction and strong/small-container rule independently, since it is generated by walking the live DOM directly rather than through the HTML clone -- and does so by walking each emitted block's (p/li/td/pre/h1-h6/blockquote) own subtree rather than its flattened textContent, fixing a real leak where a matched element sitting INLINE inside one of those blocks (<p>Your setup key: <code id="totp-secret">SEED</code></p>) was emitted in clear because only the block itself and its ancestors, never its descendants, were checked.
URL-based suppression: a page whose URL matches a known-sensitive pattern (/2fa, /two_factor, /two-step, /mfa, /totp, /security/keys, security-keys, /recovery-codes, /backup-codes, login-verification, security-info) has its html/md body capture AND its screenshot -- including an explicit screenshot action, not just auto-capture -- suppressed outright, regardless of what the page's markup looks like. Matched against the URL's pathname and hash, never its query string, and only for a URL whose pathname is a real page location: the hash is included, so a hash-routed single-page app (e.g. the AWS console's #/security_credentials/mfa route) matches; the query string is excluded because matching it over-suppressed an ordinary /login?next=/settings/2fa redirect; data:/blob:/about: URLs are excluded entirely, since a data: URL's "pathname" is its own percent-encoded document body (a page's own <title> falsely matching its own text, not any real location, previously broke this PR's own real-Chrome #init_key_code test). mfa (only) is segment-anchored, fixing an unanchored /mfa prefix over-matching /docs/mfa-best-practices. /2fa and /two_factor//two-factor are deliberately left unanchored on their suffix side: real setup routes need the open suffix (GitHub's /settings/two_factor_authentication/..., GitLab's /-/profile/two_factor_auth, 1Password's /settings/two-factor-authentication, Slack's /account/settings/2fa_app) -- segment-anchoring two_factor the same way mfa was anchored, in an earlier version of this fix, silently broke all three of those real routes. SUPERPOWERS_CHROME_ALLOW_CREDENTIAL_CAPTURE=1 actually disables this suppression, matching what the notice has always said. Configurable via SUPERPOWERS_CHROME_SENSITIVE_URL_PATTERNS (comma-separated extra regexes, ADDED to the defaults, never a replacement).
data-sen-secret/data-sen-nonce marking (#52) is unchanged.
Tests
test/lib/secret-pattern.test.mjs: the shared word-boundary detector -- negative tests for the raw-substring false positives (footprint, token-list and variants), positive tests for every real id/class this repo relies on matching, a negative control for an ordinary non-matching container id, and the strong-compound container-strength gate (isCompoundSecretMatch) with both its own positive/negative cases.
test/lib/page-scripts/html-with-scrub.test.mjs: Slack's real case (hidden input and hidden leaf-text forms), one test per pattern word, autocomplete/class/aria-label matches, unconditional attribute stripping, a negative test that an ordinary login form is untouched, button/link label preservation, a non-vacuous wrapping-container test (using a container id that actually matches the pattern AND is a strong compound), full blanking of a container whose secret is split across element children and of a recovery-codes list, a Prism-token-span regression test confirming no page-wide substring corruption of common words or class= attributes, and real-world weak-container negative tests (a design-system wrapper class, a docs landmark section) confirming they are not wiped.
test/lib/page-scripts/markdown.test.mjs: the same word-boundary redaction and strong/small-container rule applied independently to the Markdown generator, including the wrapping-container and whitespace-padded-leaf cases, a link-label preservation check, an inline-match-inside-a-block test for each of p/li/td/pre/h3/blockquote, and the same two weak-container negative tests.
test/lib/sensitive-url.test.mjs: default pattern list including the new words, the env-var extension (additive, not a replacement, malformed fragments ignored), non-string/unparseable input, hash-routed SPA matching (a generic case and AWS's real MFA route), Google's real /two-step-verification route, a security-keys hash anchor, negative tests for both over-suppression cases (/login?next=/settings/2fa, /docs/mfa-best-practices), GitHub/GitLab/1Password's real 2FA routes, and data:/blob:/about: URLs never matching (including a data: URL whose own title text would otherwise false-match).
test/lib/capture-credential-guard.test.mjs: URL suppression writes no files and takes no screenshot even with ordinary, non-credential-shaped markup; an ordinary URL is not suppressed; the suppression reason propagates through the *WithCapture wrappers; an explicit screenshot action is suppressed on a known-sensitive URL; type/hover/key actions and both dialog short-circuits are suppressed purely by URL, not just click; SUPERPOWERS_CHROME_ALLOW_CREDENTIAL_CAPTURE=1 actually restores a URL-suppressed capture/screenshot/dialog.
test/credential-guard-mcp.test.mjs (real Chrome): the PR's own headline #init_key_code end-to-end test, previously broken by the data: URL scheme fix above, passes again -- run and confirmed in this round, not assumed.
Security (round 4)
Attribute stripping inside a matched container's subtree now runs BEFORE the skip-tag early return, not after: a copy button's data-clipboard-text (holding the same seed as a sibling <code>) used to survive in the .html because the function returned before ever reaching the attribute-stripping code for that nested control. label/summary/legend/option are no longer treated as skip-tag controls either -- only button/a are, since a <label> wrapping a matched leaf is routinely the only place a secret is rendered at all; their own text is now blanked and their children recursed into, like any other element.
An otpauth:// URI is now redacted unconditionally, wherever it appears in the final .html or .md -- an <a href>, an <img src>, a data-* attribute, visible text, or a QR image's alt/title -- regardless of whether the carrying element also matches the word-boundary secret-pattern detector. href in particular was leaking completely unscrubbed before this (no prior mechanism touched it at all).
An explicit screenshot action refused by URL suppression now reports that reason, with the SUPERPOWERS_CHROME_SENSITIVE_URL_PATTERNS/ALLOW_CREDENTIAL_CAPTURE notice, instead of always saying "page shows credential-shaped content" (which was simply wrong for the URL case).
/2fa, /totp, security-keys and security-info are now anchored with a word-boundary check on their open (suffix) side: they match a whole path segment or a segment PREFIX followed by a non-alphanumeric separator or end of string (/2fa_app, /settings/2fa), but no longer match a prefix glued directly to more letters with no separator at all (/2fast, /totpal, /security-keyset-docs, /security-informant).
Tests (round 4)
test/lib/page-scripts/html-with-scrub.test.mjs: a skip-tag control's own data-* attributes stripped inside a matched container; a <label> wrapping a matched leaf fully blanked; otpauth:// redacted in an <a href>, an <img src>, a data-* attribute, plain text, and a QR image's alt/title.
test/lib/page-scripts/markdown.test.mjs: the same otpauth:// redaction, including inside a rendered [text](href) link and a QR image's alt text.
test/lib/sensitive-url.test.mjs: /2fast, /totpal, /security-keyset-docs and /security-informant no longer match; the real segment/segment-prefix routes (/settings/2fa, /2fa/setup, Slack's /2fa_app, a security-keys hash anchor) still do.
test/lib/capture-credential-guard.test.mjs: an action that navigates from a clean page to a known-sensitive URL suppresses the AFTER-action capture with suppressedReason: 'sensitive-url' (not falling back to credential-shape); ALLOW_CREDENTIAL_CAPTURE=1 restores it; a route change DURING a screenshot (the URL becomes sensitive mid-shot) deletes the screenshot via the existing post-shot re-check.
test/credential-guard-mcp.test.mjs (real Chrome): an explicit screenshot action on a known-sensitive URL reports the URL-specific refusal message, not the generic credential-shape one.
test/credential-guard-mcp.test.mjs (real Chrome): #init_key_code loaded via navigate never reaches any capture artifact (every file in the session dir checked, not just .html/.md); a file:// URL under a 2fa_app directory is suppressed end to end even though its markup is ordinary.
[3.0.10] - 2026-10-04 - Redaction catches a typed secret a page mirrors from an unrecognized field
Security
A field with no type="password" and no recognized autocomplete token was invisible to #55's redaction entirely, so a page mirroring its typed value into an attribute wrote that value to disk unredacted -- in any attribute, not just value/data-*. Google's 2-step verification page is a concrete case: its one-time-code field (#totpPin) copies the typed code into data-initial-value, which isn't type="password" or autocomplete-tagged. Such a field is now also found by comparing its live .value against its own other attributes (a self-mirror), so the existing value-redaction pass -- already attribute- and element-agnostic -- catches it. The exact-attribute-removal pass (for fields found by type/autocomplete/marker) now also strips aria-* attributes, not just data-*. #55's length floor is unchanged. Self-mirror detection skips input types nobody types into (submit, button, reset, checkbox, radio, hidden, image) and ignores attributes that name or label a field (type, name, id, aria-label, title, placeholder, for, class), so a submit button, a radio, or Google's search button whose value matches one of those is not treated as a secret.