Redact secrets from CLI logs, not just CI logs - #2412

Open
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs
Open

Redact secrets from CLI logs, not just CI logs#2412
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstackninadbstack commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through untouched:

constlogsObject={clilogs: logger.query(log=>!['ci'].includes(log.debug))// ← unredacted};
...
logsObject.cilogs=redactSecrets(logger.query(...));// ← redacted

CLI log entries are the ones that can carry foreign response text — SDK error messages interpolate fields from remote HTTP responses — and /logs content is retrievable via GET /api/v1/logs by anyone who passes authorize(build, :read?), i.e. any project member. There is no server-side redaction on that path. So the half most likely to hold upstream data was the half we weren't redacting.

This routes clilogs through the same existing redactSecrets helper. No new patterns, no new mechanism — secretPatterns.yml already ships ~1750 rules including AWS Access Key ID / cred-file shapes.

Why the memoization is in the same PR

redactSecrets recurses per log entry, and each call did readFileSync + YAML.parse of the ~1750-rule pattern file and recompiled every regex. That was already wasteful for cilogs; sending clilogs (the larger set) through it would have made it a real cost on big builds. Patterns are now parsed and compiled once.

Reusing the /g regexes across replace() calls is safe — String.prototype.replace resets lastIndex on a global pattern, verified:

0 "x [REDACTED] y" lastIndex= 0
1 "x [REDACTED] y" lastIndex= 0
2 "x [REDACTED] y" lastIndex= 0

Behavior notes

  • No change for legitimate users or for support: the full message is preserved, only credential-shaped substrings become [REDACTED].
  • The in-place mutation of logger entries is unchanged from the existing cilogs path and matches the contract documented at packages/logger/src/logger.js:137-146.

Testing

Added redacts secrets from CLI logs, not just CI logs to packages/core/test/percy.test.js — decodes the actual posted /logs payload and asserts the key shape is absent from clilogs and [REDACTED] is present.

Draft because I could not run the suitenode_modules isn't installed in my environment and installing deps is out of scope there. Files pass node --check; the regex-reuse claim above is verified standalone. Please let CI run before marking ready.

Context

Found while assessing PER-9354. That chain finding itself does not reproduce (details in the ticket) — this is an independent gap surfaced along the way, and it stands on its own merits.

🤖 Generated with Claude Code

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through
untouched. CLI log entries can carry upstream response text — SDK errors
interpolate remote response fields into their messages — and /logs content
is retrievable via GET /api/v1/logs by anyone with build read access, so
the half that most often holds foreign response data was the unredacted
half.
Also memoize the pattern file. redactSecrets recurses per log entry and
re-read + re-parsed the ~1750-rule YAML and recompiled every regex on each
call; that was tolerable for cilogs alone but not once clilogs (the larger
set) goes through it. Patterns are now parsed and compiled once. Reusing
the global regexes across replace() calls is safe — replace() resets
lastIndex on a global pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack
ninadbstack marked this pull request as ready for review September 1, 2026 11:23
@ninadbstack
ninadbstack requested a review from a team as a code ownerSeptember 1, 2026 11:23
Master landed the same fix in #2279 (security: redact CLI logs, bound
regex matching). Both conflicting hunks resolved in favour of master's
version, which is a superset of this branch's: same redactSecrets call
on clilogs in sendBuildLogs, same compile-patterns-once memoization,
plus in-place entry mutation, the ReDoS bound and a semgrep annotation
this branch didn't have.
What remains of this branch is the integration-level sendBuildLogs
redaction test; master's coverage for #2279 is unit-level in
test/unit/utils.test.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
percy.build = { id: 1 };
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
percy.log.info('ci side: ASIAY34FZKBOKMUTVV7A', {}, true);

const clilogs = JSON.stringify(sent.clilogs);
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ninadbstack@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Redact secrets from CLI logs, not just CI logs - #2412

Open
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs
Open

Redact secrets from CLI logs, not just CI logs#2412
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstackninadbstack commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through untouched:

constlogsObject={clilogs: logger.query(log=>!['ci'].includes(log.debug))// ← unredacted};
...
logsObject.cilogs=redactSecrets(logger.query(...));// ← redacted

CLI log entries are the ones that can carry foreign response text — SDK error messages interpolate fields from remote HTTP responses — and /logs content is retrievable via GET /api/v1/logs by anyone who passes authorize(build, :read?), i.e. any project member. There is no server-side redaction on that path. So the half most likely to hold upstream data was the half we weren't redacting.

This routes clilogs through the same existing redactSecrets helper. No new patterns, no new mechanism — secretPatterns.yml already ships ~1750 rules including AWS Access Key ID / cred-file shapes.

Why the memoization is in the same PR

redactSecrets recurses per log entry, and each call did readFileSync + YAML.parse of the ~1750-rule pattern file and recompiled every regex. That was already wasteful for cilogs; sending clilogs (the larger set) through it would have made it a real cost on big builds. Patterns are now parsed and compiled once.

Reusing the /g regexes across replace() calls is safe — String.prototype.replace resets lastIndex on a global pattern, verified:

0 "x [REDACTED] y" lastIndex= 0
1 "x [REDACTED] y" lastIndex= 0
2 "x [REDACTED] y" lastIndex= 0

Behavior notes

  • No change for legitimate users or for support: the full message is preserved, only credential-shaped substrings become [REDACTED].
  • The in-place mutation of logger entries is unchanged from the existing cilogs path and matches the contract documented at packages/logger/src/logger.js:137-146.

Testing

Added redacts secrets from CLI logs, not just CI logs to packages/core/test/percy.test.js — decodes the actual posted /logs payload and asserts the key shape is absent from clilogs and [REDACTED] is present.

Draft because I could not run the suitenode_modules isn't installed in my environment and installing deps is out of scope there. Files pass node --check; the regex-reuse claim above is verified standalone. Please let CI run before marking ready.

Context

Found while assessing PER-9354. That chain finding itself does not reproduce (details in the ticket) — this is an independent gap surfaced along the way, and it stands on its own merits.

🤖 Generated with Claude Code

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through
untouched. CLI log entries can carry upstream response text — SDK errors
interpolate remote response fields into their messages — and /logs content
is retrievable via GET /api/v1/logs by anyone with build read access, so
the half that most often holds foreign response data was the unredacted
half.
Also memoize the pattern file. redactSecrets recurses per log entry and
re-read + re-parsed the ~1750-rule YAML and recompiled every regex on each
call; that was tolerable for cilogs alone but not once clilogs (the larger
set) goes through it. Patterns are now parsed and compiled once. Reusing
the global regexes across replace() calls is safe — replace() resets
lastIndex on a global pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack
ninadbstack marked this pull request as ready for review September 1, 2026 11:23
@ninadbstack
ninadbstack requested a review from a team as a code ownerSeptember 1, 2026 11:23
Master landed the same fix in #2279 (security: redact CLI logs, bound
regex matching). Both conflicting hunks resolved in favour of master's
version, which is a superset of this branch's: same redactSecrets call
on clilogs in sendBuildLogs, same compile-patterns-once memoization,
plus in-place entry mutation, the ReDoS bound and a semgrep annotation
this branch didn't have.
What remains of this branch is the integration-level sendBuildLogs
redaction test; master's coverage for #2279 is unit-level in
test/unit/utils.test.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
percy.build = { id: 1 };
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
percy.log.info('ci side: ASIAY34FZKBOKMUTVV7A', {}, true);

const clilogs = JSON.stringify(sent.clilogs);
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ninadbstack@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Redact secrets from CLI logs, not just CI logs - #2412

Open
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs
Open

Redact secrets from CLI logs, not just CI logs#2412
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstackninadbstack commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through untouched:

constlogsObject={clilogs: logger.query(log=>!['ci'].includes(log.debug))// ← unredacted};
...
logsObject.cilogs=redactSecrets(logger.query(...));// ← redacted

CLI log entries are the ones that can carry foreign response text — SDK error messages interpolate fields from remote HTTP responses — and /logs content is retrievable via GET /api/v1/logs by anyone who passes authorize(build, :read?), i.e. any project member. There is no server-side redaction on that path. So the half most likely to hold upstream data was the half we weren't redacting.

This routes clilogs through the same existing redactSecrets helper. No new patterns, no new mechanism — secretPatterns.yml already ships ~1750 rules including AWS Access Key ID / cred-file shapes.

Why the memoization is in the same PR

redactSecrets recurses per log entry, and each call did readFileSync + YAML.parse of the ~1750-rule pattern file and recompiled every regex. That was already wasteful for cilogs; sending clilogs (the larger set) through it would have made it a real cost on big builds. Patterns are now parsed and compiled once.

Reusing the /g regexes across replace() calls is safe — String.prototype.replace resets lastIndex on a global pattern, verified:

0 "x [REDACTED] y" lastIndex= 0
1 "x [REDACTED] y" lastIndex= 0
2 "x [REDACTED] y" lastIndex= 0

Behavior notes

  • No change for legitimate users or for support: the full message is preserved, only credential-shaped substrings become [REDACTED].
  • The in-place mutation of logger entries is unchanged from the existing cilogs path and matches the contract documented at packages/logger/src/logger.js:137-146.

Testing

Added redacts secrets from CLI logs, not just CI logs to packages/core/test/percy.test.js — decodes the actual posted /logs payload and asserts the key shape is absent from clilogs and [REDACTED] is present.

Draft because I could not run the suitenode_modules isn't installed in my environment and installing deps is out of scope there. Files pass node --check; the regex-reuse claim above is verified standalone. Please let CI run before marking ready.

Context

Found while assessing PER-9354. That chain finding itself does not reproduce (details in the ticket) — this is an independent gap surfaced along the way, and it stands on its own merits.

🤖 Generated with Claude Code

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through
untouched. CLI log entries can carry upstream response text — SDK errors
interpolate remote response fields into their messages — and /logs content
is retrievable via GET /api/v1/logs by anyone with build read access, so
the half that most often holds foreign response data was the unredacted
half.
Also memoize the pattern file. redactSecrets recurses per log entry and
re-read + re-parsed the ~1750-rule YAML and recompiled every regex on each
call; that was tolerable for cilogs alone but not once clilogs (the larger
set) goes through it. Patterns are now parsed and compiled once. Reusing
the global regexes across replace() calls is safe — replace() resets
lastIndex on a global pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack
ninadbstack marked this pull request as ready for review September 1, 2026 11:23
@ninadbstack
ninadbstack requested a review from a team as a code ownerSeptember 1, 2026 11:23
Master landed the same fix in #2279 (security: redact CLI logs, bound
regex matching). Both conflicting hunks resolved in favour of master's
version, which is a superset of this branch's: same redactSecrets call
on clilogs in sendBuildLogs, same compile-patterns-once memoization,
plus in-place entry mutation, the ReDoS bound and a semgrep annotation
this branch didn't have.
What remains of this branch is the integration-level sendBuildLogs
redaction test; master's coverage for #2279 is unit-level in
test/unit/utils.test.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
percy.build = { id: 1 };
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
percy.log.info('ci side: ASIAY34FZKBOKMUTVV7A', {}, true);

const clilogs = JSON.stringify(sent.clilogs);
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ninadbstack@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Redact secrets from CLI logs, not just CI logs - #2412

Open
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs
Open

Redact secrets from CLI logs, not just CI logs#2412
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstackninadbstack commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through untouched:

constlogsObject={clilogs: logger.query(log=>!['ci'].includes(log.debug))// ← unredacted};
...
logsObject.cilogs=redactSecrets(logger.query(...));// ← redacted

CLI log entries are the ones that can carry foreign response text — SDK error messages interpolate fields from remote HTTP responses — and /logs content is retrievable via GET /api/v1/logs by anyone who passes authorize(build, :read?), i.e. any project member. There is no server-side redaction on that path. So the half most likely to hold upstream data was the half we weren't redacting.

This routes clilogs through the same existing redactSecrets helper. No new patterns, no new mechanism — secretPatterns.yml already ships ~1750 rules including AWS Access Key ID / cred-file shapes.

Why the memoization is in the same PR

redactSecrets recurses per log entry, and each call did readFileSync + YAML.parse of the ~1750-rule pattern file and recompiled every regex. That was already wasteful for cilogs; sending clilogs (the larger set) through it would have made it a real cost on big builds. Patterns are now parsed and compiled once.

Reusing the /g regexes across replace() calls is safe — String.prototype.replace resets lastIndex on a global pattern, verified:

0 "x [REDACTED] y" lastIndex= 0
1 "x [REDACTED] y" lastIndex= 0
2 "x [REDACTED] y" lastIndex= 0

Behavior notes

  • No change for legitimate users or for support: the full message is preserved, only credential-shaped substrings become [REDACTED].
  • The in-place mutation of logger entries is unchanged from the existing cilogs path and matches the contract documented at packages/logger/src/logger.js:137-146.

Testing

Added redacts secrets from CLI logs, not just CI logs to packages/core/test/percy.test.js — decodes the actual posted /logs payload and asserts the key shape is absent from clilogs and [REDACTED] is present.

Draft because I could not run the suitenode_modules isn't installed in my environment and installing deps is out of scope there. Files pass node --check; the regex-reuse claim above is verified standalone. Please let CI run before marking ready.

Context

Found while assessing PER-9354. That chain finding itself does not reproduce (details in the ticket) — this is an independent gap surfaced along the way, and it stands on its own merits.

🤖 Generated with Claude Code

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through
untouched. CLI log entries can carry upstream response text — SDK errors
interpolate remote response fields into their messages — and /logs content
is retrievable via GET /api/v1/logs by anyone with build read access, so
the half that most often holds foreign response data was the unredacted
half.
Also memoize the pattern file. redactSecrets recurses per log entry and
re-read + re-parsed the ~1750-rule YAML and recompiled every regex on each
call; that was tolerable for cilogs alone but not once clilogs (the larger
set) goes through it. Patterns are now parsed and compiled once. Reusing
the global regexes across replace() calls is safe — replace() resets
lastIndex on a global pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack
ninadbstack marked this pull request as ready for review September 1, 2026 11:23
@ninadbstack
ninadbstack requested a review from a team as a code ownerSeptember 1, 2026 11:23
Master landed the same fix in #2279 (security: redact CLI logs, bound
regex matching). Both conflicting hunks resolved in favour of master's
version, which is a superset of this branch's: same redactSecrets call
on clilogs in sendBuildLogs, same compile-patterns-once memoization,
plus in-place entry mutation, the ReDoS bound and a semgrep annotation
this branch didn't have.
What remains of this branch is the integration-level sendBuildLogs
redaction test; master's coverage for #2279 is unit-level in
test/unit/utils.test.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
percy.build = { id: 1 };
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
percy.log.info('ci side: ASIAY34FZKBOKMUTVV7A', {}, true);

const clilogs = JSON.stringify(sent.clilogs);
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ninadbstack@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Redact secrets from CLI logs, not just CI logs - #2412

Open
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs
Open

Redact secrets from CLI logs, not just CI logs#2412
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstackninadbstack commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through untouched:

constlogsObject={clilogs: logger.query(log=>!['ci'].includes(log.debug))// ← unredacted};
...
logsObject.cilogs=redactSecrets(logger.query(...));// ← redacted

CLI log entries are the ones that can carry foreign response text — SDK error messages interpolate fields from remote HTTP responses — and /logs content is retrievable via GET /api/v1/logs by anyone who passes authorize(build, :read?), i.e. any project member. There is no server-side redaction on that path. So the half most likely to hold upstream data was the half we weren't redacting.

This routes clilogs through the same existing redactSecrets helper. No new patterns, no new mechanism — secretPatterns.yml already ships ~1750 rules including AWS Access Key ID / cred-file shapes.

Why the memoization is in the same PR

redactSecrets recurses per log entry, and each call did readFileSync + YAML.parse of the ~1750-rule pattern file and recompiled every regex. That was already wasteful for cilogs; sending clilogs (the larger set) through it would have made it a real cost on big builds. Patterns are now parsed and compiled once.

Reusing the /g regexes across replace() calls is safe — String.prototype.replace resets lastIndex on a global pattern, verified:

0 "x [REDACTED] y" lastIndex= 0
1 "x [REDACTED] y" lastIndex= 0
2 "x [REDACTED] y" lastIndex= 0

Behavior notes

  • No change for legitimate users or for support: the full message is preserved, only credential-shaped substrings become [REDACTED].
  • The in-place mutation of logger entries is unchanged from the existing cilogs path and matches the contract documented at packages/logger/src/logger.js:137-146.

Testing

Added redacts secrets from CLI logs, not just CI logs to packages/core/test/percy.test.js — decodes the actual posted /logs payload and asserts the key shape is absent from clilogs and [REDACTED] is present.

Draft because I could not run the suitenode_modules isn't installed in my environment and installing deps is out of scope there. Files pass node --check; the regex-reuse claim above is verified standalone. Please let CI run before marking ready.

Context

Found while assessing PER-9354. That chain finding itself does not reproduce (details in the ticket) — this is an independent gap surfaced along the way, and it stands on its own merits.

🤖 Generated with Claude Code

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through
untouched. CLI log entries can carry upstream response text — SDK errors
interpolate remote response fields into their messages — and /logs content
is retrievable via GET /api/v1/logs by anyone with build read access, so
the half that most often holds foreign response data was the unredacted
half.
Also memoize the pattern file. redactSecrets recurses per log entry and
re-read + re-parsed the ~1750-rule YAML and recompiled every regex on each
call; that was tolerable for cilogs alone but not once clilogs (the larger
set) goes through it. Patterns are now parsed and compiled once. Reusing
the global regexes across replace() calls is safe — replace() resets
lastIndex on a global pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack
ninadbstack marked this pull request as ready for review September 1, 2026 11:23
@ninadbstack
ninadbstack requested a review from a team as a code ownerSeptember 1, 2026 11:23
Master landed the same fix in #2279 (security: redact CLI logs, bound
regex matching). Both conflicting hunks resolved in favour of master's
version, which is a superset of this branch's: same redactSecrets call
on clilogs in sendBuildLogs, same compile-patterns-once memoization,
plus in-place entry mutation, the ReDoS bound and a semgrep annotation
this branch didn't have.
What remains of this branch is the integration-level sendBuildLogs
redaction test; master's coverage for #2279 is unit-level in
test/unit/utils.test.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
percy.build = { id: 1 };
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
percy.log.info('ci side: ASIAY34FZKBOKMUTVV7A', {}, true);

const clilogs = JSON.stringify(sent.clilogs);
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ninadbstack@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Redact secrets from CLI logs, not just CI logs - #2412

Open
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs
Open

Redact secrets from CLI logs, not just CI logs#2412
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstackninadbstack commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through untouched:

constlogsObject={clilogs: logger.query(log=>!['ci'].includes(log.debug))// ← unredacted};
...
logsObject.cilogs=redactSecrets(logger.query(...));// ← redacted

CLI log entries are the ones that can carry foreign response text — SDK error messages interpolate fields from remote HTTP responses — and /logs content is retrievable via GET /api/v1/logs by anyone who passes authorize(build, :read?), i.e. any project member. There is no server-side redaction on that path. So the half most likely to hold upstream data was the half we weren't redacting.

This routes clilogs through the same existing redactSecrets helper. No new patterns, no new mechanism — secretPatterns.yml already ships ~1750 rules including AWS Access Key ID / cred-file shapes.

Why the memoization is in the same PR

redactSecrets recurses per log entry, and each call did readFileSync + YAML.parse of the ~1750-rule pattern file and recompiled every regex. That was already wasteful for cilogs; sending clilogs (the larger set) through it would have made it a real cost on big builds. Patterns are now parsed and compiled once.

Reusing the /g regexes across replace() calls is safe — String.prototype.replace resets lastIndex on a global pattern, verified:

0 "x [REDACTED] y" lastIndex= 0
1 "x [REDACTED] y" lastIndex= 0
2 "x [REDACTED] y" lastIndex= 0

Behavior notes

  • No change for legitimate users or for support: the full message is preserved, only credential-shaped substrings become [REDACTED].
  • The in-place mutation of logger entries is unchanged from the existing cilogs path and matches the contract documented at packages/logger/src/logger.js:137-146.

Testing

Added redacts secrets from CLI logs, not just CI logs to packages/core/test/percy.test.js — decodes the actual posted /logs payload and asserts the key shape is absent from clilogs and [REDACTED] is present.

Draft because I could not run the suitenode_modules isn't installed in my environment and installing deps is out of scope there. Files pass node --check; the regex-reuse claim above is verified standalone. Please let CI run before marking ready.

Context

Found while assessing PER-9354. That chain finding itself does not reproduce (details in the ticket) — this is an independent gap surfaced along the way, and it stands on its own merits.

🤖 Generated with Claude Code

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through
untouched. CLI log entries can carry upstream response text — SDK errors
interpolate remote response fields into their messages — and /logs content
is retrievable via GET /api/v1/logs by anyone with build read access, so
the half that most often holds foreign response data was the unredacted
half.
Also memoize the pattern file. redactSecrets recurses per log entry and
re-read + re-parsed the ~1750-rule YAML and recompiled every regex on each
call; that was tolerable for cilogs alone but not once clilogs (the larger
set) goes through it. Patterns are now parsed and compiled once. Reusing
the global regexes across replace() calls is safe — replace() resets
lastIndex on a global pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack
ninadbstack marked this pull request as ready for review September 1, 2026 11:23
@ninadbstack
ninadbstack requested a review from a team as a code ownerSeptember 1, 2026 11:23
Master landed the same fix in #2279 (security: redact CLI logs, bound
regex matching). Both conflicting hunks resolved in favour of master's
version, which is a superset of this branch's: same redactSecrets call
on clilogs in sendBuildLogs, same compile-patterns-once memoization,
plus in-place entry mutation, the ReDoS bound and a semgrep annotation
this branch didn't have.
What remains of this branch is the integration-level sendBuildLogs
redaction test; master's coverage for #2279 is unit-level in
test/unit/utils.test.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
percy.build = { id: 1 };
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
percy.log.info('ci side: ASIAY34FZKBOKMUTVV7A', {}, true);

const clilogs = JSON.stringify(sent.clilogs);
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ninadbstack@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Redact secrets from CLI logs, not just CI logs - #2412

Open
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs
Open

Redact secrets from CLI logs, not just CI logs#2412
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstackninadbstack commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through untouched:

constlogsObject={clilogs: logger.query(log=>!['ci'].includes(log.debug))// ← unredacted};
...
logsObject.cilogs=redactSecrets(logger.query(...));// ← redacted

CLI log entries are the ones that can carry foreign response text — SDK error messages interpolate fields from remote HTTP responses — and /logs content is retrievable via GET /api/v1/logs by anyone who passes authorize(build, :read?), i.e. any project member. There is no server-side redaction on that path. So the half most likely to hold upstream data was the half we weren't redacting.

This routes clilogs through the same existing redactSecrets helper. No new patterns, no new mechanism — secretPatterns.yml already ships ~1750 rules including AWS Access Key ID / cred-file shapes.

Why the memoization is in the same PR

redactSecrets recurses per log entry, and each call did readFileSync + YAML.parse of the ~1750-rule pattern file and recompiled every regex. That was already wasteful for cilogs; sending clilogs (the larger set) through it would have made it a real cost on big builds. Patterns are now parsed and compiled once.

Reusing the /g regexes across replace() calls is safe — String.prototype.replace resets lastIndex on a global pattern, verified:

0 "x [REDACTED] y" lastIndex= 0
1 "x [REDACTED] y" lastIndex= 0
2 "x [REDACTED] y" lastIndex= 0

Behavior notes

  • No change for legitimate users or for support: the full message is preserved, only credential-shaped substrings become [REDACTED].
  • The in-place mutation of logger entries is unchanged from the existing cilogs path and matches the contract documented at packages/logger/src/logger.js:137-146.

Testing

Added redacts secrets from CLI logs, not just CI logs to packages/core/test/percy.test.js — decodes the actual posted /logs payload and asserts the key shape is absent from clilogs and [REDACTED] is present.

Draft because I could not run the suitenode_modules isn't installed in my environment and installing deps is out of scope there. Files pass node --check; the regex-reuse claim above is verified standalone. Please let CI run before marking ready.

Context

Found while assessing PER-9354. That chain finding itself does not reproduce (details in the ticket) — this is an independent gap surfaced along the way, and it stands on its own merits.

🤖 Generated with Claude Code

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through
untouched. CLI log entries can carry upstream response text — SDK errors
interpolate remote response fields into their messages — and /logs content
is retrievable via GET /api/v1/logs by anyone with build read access, so
the half that most often holds foreign response data was the unredacted
half.
Also memoize the pattern file. redactSecrets recurses per log entry and
re-read + re-parsed the ~1750-rule YAML and recompiled every regex on each
call; that was tolerable for cilogs alone but not once clilogs (the larger
set) goes through it. Patterns are now parsed and compiled once. Reusing
the global regexes across replace() calls is safe — replace() resets
lastIndex on a global pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack
ninadbstack marked this pull request as ready for review September 1, 2026 11:23
@ninadbstack
ninadbstack requested a review from a team as a code ownerSeptember 1, 2026 11:23
Master landed the same fix in #2279 (security: redact CLI logs, bound
regex matching). Both conflicting hunks resolved in favour of master's
version, which is a superset of this branch's: same redactSecrets call
on clilogs in sendBuildLogs, same compile-patterns-once memoization,
plus in-place entry mutation, the ReDoS bound and a semgrep annotation
this branch didn't have.
What remains of this branch is the integration-level sendBuildLogs
redaction test; master's coverage for #2279 is unit-level in
test/unit/utils.test.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
percy.build = { id: 1 };
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
percy.log.info('ci side: ASIAY34FZKBOKMUTVV7A', {}, true);

const clilogs = JSON.stringify(sent.clilogs);
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ninadbstack@github-advanced-security
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Redact secrets from CLI logs, not just CI logs - #2412

Open
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs
Open

Redact secrets from CLI logs, not just CI logs#2412
ninadbstack wants to merge 2 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstackninadbstack commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through untouched:

constlogsObject={clilogs: logger.query(log=>!['ci'].includes(log.debug))// ← unredacted};
...
logsObject.cilogs=redactSecrets(logger.query(...));// ← redacted

CLI log entries are the ones that can carry foreign response text — SDK error messages interpolate fields from remote HTTP responses — and /logs content is retrievable via GET /api/v1/logs by anyone who passes authorize(build, :read?), i.e. any project member. There is no server-side redaction on that path. So the half most likely to hold upstream data was the half we weren't redacting.

This routes clilogs through the same existing redactSecrets helper. No new patterns, no new mechanism — secretPatterns.yml already ships ~1750 rules including AWS Access Key ID / cred-file shapes.

Why the memoization is in the same PR

redactSecrets recurses per log entry, and each call did readFileSync + YAML.parse of the ~1750-rule pattern file and recompiled every regex. That was already wasteful for cilogs; sending clilogs (the larger set) through it would have made it a real cost on big builds. Patterns are now parsed and compiled once.

Reusing the /g regexes across replace() calls is safe — String.prototype.replace resets lastIndex on a global pattern, verified:

0 "x [REDACTED] y" lastIndex= 0
1 "x [REDACTED] y" lastIndex= 0
2 "x [REDACTED] y" lastIndex= 0

Behavior notes

  • No change for legitimate users or for support: the full message is preserved, only credential-shaped substrings become [REDACTED].
  • The in-place mutation of logger entries is unchanged from the existing cilogs path and matches the contract documented at packages/logger/src/logger.js:137-146.

Testing

Added redacts secrets from CLI logs, not just CI logs to packages/core/test/percy.test.js — decodes the actual posted /logs payload and asserts the key shape is absent from clilogs and [REDACTED] is present.

Draft because I could not run the suitenode_modules isn't installed in my environment and installing deps is out of scope there. Files pass node --check; the regex-reuse claim above is verified standalone. Please let CI run before marking ready.

Context

Found while assessing PER-9354. That chain finding itself does not reproduce (details in the ticket) — this is an independent gap surfaced along the way, and it stands on its own merits.

🤖 Generated with Claude Code

sendBuildLogs applied redactSecrets to cilogs but sent clilogs through
untouched. CLI log entries can carry upstream response text — SDK errors
interpolate remote response fields into their messages — and /logs content
is retrievable via GET /api/v1/logs by anyone with build read access, so
the half that most often holds foreign response data was the unredacted
half.
Also memoize the pattern file. redactSecrets recurses per log entry and
re-read + re-parsed the ~1750-rule YAML and recompiled every regex on each
call; that was tolerable for cilogs alone but not once clilogs (the larger
set) goes through it. Patterns are now parsed and compiled once. Reusing
the global regexes across replace() calls is safe — replace() resets
lastIndex on a global pattern.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack
ninadbstack marked this pull request as ready for review September 1, 2026 11:23
@ninadbstack
ninadbstack requested a review from a team as a code ownerSeptember 1, 2026 11:23
Master landed the same fix in #2279 (security: redact CLI logs, bound
regex matching). Both conflicting hunks resolved in favour of master's
version, which is a superset of this branch's: same redactSecrets call
on clilogs in sendBuildLogs, same compile-patterns-once memoization,
plus in-place entry mutation, the ReDoS bound and a semgrep annotation
this branch didn't have.
What remains of this branch is the integration-level sendBuildLogs
redaction test; master's coverage for #2279 is unit-level in
test/unit/utils.test.js.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
percy.build = { id: 1 };
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
// A CLI-side log entry can carry upstream response text, so it needs the
// same redaction cilogs already gets.
percy.log.info('leaked from upstream: ASIAY34FZKBOKMUTVV7A');
percy.log.info('ci side: ASIAY34FZKBOKMUTVV7A', {}, true);

const clilogs = JSON.stringify(sent.clilogs);
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
const cilogs = JSON.stringify(sent.cilogs);
expect(clilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain('ASIAY34FZKBOKMUTVV7A');
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ninadbstack@github-advanced-security