Skip to content

test(core): cover clilogs secret redaction in sendBuildLogs - #2412

Merged
ninadbstack merged 4 commits into
masterfrom
PER-9354-redact-clilogs
Sep 7, 2026
Merged

ninadbstack merged 4 commits into
masterfrom
PER-9354-redact-clilogs

Conversation

@ninadbstack

@ninadbstack ninadbstack commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds regression coverage for clilogs secret redaction in sendBuildLogs. Test-only — the fix itself is already on master.

Why this is test-only

I wrote this branch to fix sendBuildLogs applying redactSecrets to cilogs but not clilogs. That fix had already landed in #2279 (3f329840, "security: redact CLI logs, bound regex matching (ReDoS)") as its PER-8609 hunk, along with the secretPatterns.yml memoization, for the same reason I'd hit: redactSecrets re-read and re-parsed ~1.7k regexes per recursive call, which becomes real once the full CLI log array goes through it.

Merging master in resolved the overlap in master's favour, so packages/core/src/percy.js and packages/core/src/utils.js on this branch are now byte-identical to master. The only remaining delta is the spec.

What #2279 did not add is a test — grep -n 'REDACTED\|redact' packages/core/test/percy.test.js on master returns nothing. So the redaction on the clilogs path is currently unguarded against regression. This adds that guard.

The test

redacts secrets from CLI logs, not just CI logs in packages/core/test/percy.test.js decodes the actual gzipped /logs payload and asserts, for both halves, that the credential-shaped string is gone and [REDACTED] is present.

Two details worth calling out for review:

  • The CI-tagged entry is written via logger.instance.log('ci', 'info', …). percy.log is a namespace group bound as log(name, level, …), and Logger#log only takes (debug, level, message, meta) — so the percy.log.info(msg, {}, true) idiom used by the neighbouring specs silently drops the third arg, and the entry lands in clilogs with debug === 'percy'. This mirrors how packages/cli-exec/src/exec.js:140 produces CI entries with logger('ci'). (logger in this test file is the helpers object, not callable, hence going through .instance.)
  • Both arrays are asserted non-empty, so neither expect(...).not.toContain(key) can pass vacuously against an empty [].

Semgrep

Proving redaction needs a string the pattern file matches, and the obvious choice — a literal AWS access key ID — trips Semgrep's hardcoded-credential rule. semgrep ci is diff-aware, so this file was the only thing scanned and it produced 4 blocking findings, one per copy of the literal.

Worth recording for the next person, since the repo has hit this before: an inline // nosemgrep is not sufficient. It takes semgrep ci from 4 blocking findings to 0 and the job exits 0, but the suppressed finding still lands in semgrep.sarif, which the workflow uploads to GitHub code scanning — so the Semgrep OSS check independently reported "1 new alert including 1 error" and stayed red. That partially reconciles the two conflicting notes in the tree: .semgrepignore says twice that inline // nosemgrep "is not honored by the CI semgrep version", while semgrep.yml pins image 1.164.0 to fix exactly that. Both are half right — it is honored for the exit code, not for the SARIF.

So the spec matches secretPatterns.yml's "AWS cred file info" rule instead, which keys on the bare aws_secret_access_key keyword rather than on a key value:

- pattern:
    name: AWS cred file info
    regex: (aws_access_key_id|aws_secret_access_key)

Semgrep's hardcoded-credential rules need an actual secret value, so there is nothing left to flag and no suppression is needed anywhere. This doesn't weaken the spec — the unit under test is sendBuildLogs applying redactSecrets to both halves, not any particular regex in the pattern file — and it leaves no credential-shaped literal in the repo, which is better than a suppressed one.

Rejected alternatives: assembling the key at runtime (['ASIA', …].join('')) keeps the AWS shape but relies on semgrep not constant-folding it; adding packages/core/test/percy.test.js to .semgrepignore would drop scanning on a 2000-line file to silence one line.

Testing

Linux Test @percy/core is green: the spec passes on the first attempt with no retries consumed, Executed 1240 of 1240 specs SUCCESS. Both Semgrep checks are green.

Windows went green on the previous push only via the spec-level retry: the first attempt failed on Discovery captures favicon when the server provides one (packages/core/test/discovery.test.js:407), a pre-existing flake in a different spec file, annotated by CI as "@percy/core flaked on the first attempt and was recovered by a spec-level retry (tracked in PER-9011)". This spec itself passed on Windows on the first attempt.

Note that semgrep/ci is also red on master (8cb91228) independently of this branch, since the non-diff-aware push scan reports pre-existing findings.

I could not run the suite or ESLint locally (no node_modules in my environment), so CI is the verification for both.

Context

Found while assessing PER-9354. That chain finding does not reproduce (details in the ticket); this coverage gap is an independent thing surfaced along the way.

🤖 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 owner September 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>
Comment thread packages/core/test/percy.test.js Fixed
Comment thread packages/core/test/percy.test.js Fixed
Comment thread packages/core/test/percy.test.js Fixed
Comment thread packages/core/test/percy.test.js Fixed
…on real

Two fixes to the clilogs redaction spec:

Semgrep. `semgrep ci` runs diff-aware, so the spec's four literal copies of
ASIAY34FZKBOKMUTVV7A were the only thing scanned and produced 4 blocking
findings from one hardcoded-AWS-credential Code rule (exit 1). The string is
the published AWS documentation sample for an STS temporary access key ID, not
a live credential. Hoist it to a single `fakeAwsKey` const so there is one
literal, and suppress that one line with `// nosemgrep` -- the preceding-line
form already used in packages/dom/test, honored because the workflow pins
semgrep 1.164.0 for exactly this reason.

Vacuous assertion. `percy.log` is a namespace group bound as
log(name, level, ...), and Logger#log only takes (debug, level, message, meta),
so the third `true` arg in percy.log.info(msg, {}, true) was silently dropped.
That entry landed in clilogs with debug === 'percy', leaving cilogs empty, so
`expect(cilogs).not.toContain(key)` was asserting against '[]'. Write the
CI-tagged entry through logger.instance.log('ci', 'info', ...) -- matching how
cli-exec/src/exec.js:140 does it via logger('ci') -- and assert both arrays are
non-empty plus that both carry [REDACTED].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ninadbstack ninadbstack changed the title Redact secrets from CLI logs, not just CI logs test(core): cover clilogs secret redaction in sendBuildLogs Sep 4, 2026
Comment thread packages/core/test/percy.test.js Fixed
`// nosemgrep` took semgrep/ci from 4 blocking findings to 0, but the
suppressed finding still lands in the SARIF, so GitHub code scanning raised
it anyway and the "Semgrep OSS" check reported "1 new alert including 1
error". Suppressing semgrep's own exit code is not enough.

Drop the credential-shaped literal entirely and match secretPatterns.yml's
"AWS cred file info" rule instead, which keys on the bare
`aws_secret_access_key` keyword rather than on a key value. Semgrep's
hardcoded-credential rules need an actual secret value, so there is nothing
left to flag and no suppression is needed.

This does not weaken the spec: the unit under test is sendBuildLogs applying
redactSecrets to both log halves, not any particular regex in the pattern
file, so any string the file matches serves equally well. It also leaves no
credential-shaped literal in the repo, which is better hygiene than a
suppressed one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@ninadbstack ninadbstack left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.

Comment on lines +1555 to +1562
// Matches the "AWS cred file info" rule in secretPatterns.yml, which keys
// on the bare `aws_secret_access_key` keyword rather than on a key value.
// That exercises redaction without committing anything credential-shaped:
// a real key literal trips Semgrep's hardcoded-credential rule, and an
// inline `// nosemgrep` only zeroes semgrep's own blocking count -- the
// finding still reaches the SARIF and surfaces as a code-scanning alert.
// The unit under test here is sendBuildLogs applying redactSecrets, not
// any particular regex in the pattern file, so any matching string works.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Inline commentary is unusually long for a single spec

Roughly 14 comment lines across two blocks. The content is accurate and the two non-obvious parts genuinely earn their place — why a keyword pattern rather than a key literal, and why a CI-tagged entry has to go through logger.instance. The Semgrep/nosemgrep-SARIF paragraph, though, is repo-process history rather than something a reader needs in order to understand the test.

Suggestion: Optionally condense the Semgrep rationale to a single line (the PR description already records the full story for posterity), keeping the pattern-choice and logger.instance notes. Not blocking.

Reviewer: stack-code-reviewer

expect(clilogs).not.toContain(secretish);
expect(clilogs).toContain('[REDACTED]');
expect(cilogs).not.toContain(secretish);
expect(cilogs).toContain('[REDACTED]');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] No case pairs PERCY_CLIENT_ERROR_LOGS=false with a secret-bearing clilogs entry

This spec always sends both halves, so it does not confirm that clilogs redaction is independent of the CI-log toggle. The existing should not add CI logs if PERCY_CLIENT_ERROR_LOGS is false spec (:1474) carries no secret fixture, so that combination is currently uncovered.

Suggestion: Optionally add a short case setting PERCY_CLIENT_ERROR_LOGS=false with a secret in a CLI-side entry, asserting clilogs is still redacted and cilogs is absent. Not required for this regression guard to be effective.

Reviewer: stack-code-reviewer

@ninadbstack

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #2412 • Head: 32af7aa • Reviewers: stack-code-reviewer

Summary

Test-only PR adding a regression spec — redacts secrets from CLI logs, not just CI logs — to packages/core/test/percy.test.js, guarding the clilogs branch of redactSecrets in Percy#sendBuildLogs. The production fix itself already landed on master in #2279; packages/core/src/percy.js and packages/core/src/utils.js on this branch were confirmed byte-identical to master, so the new spec is the entire delta (49 added lines, one file).

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass The head commit replaced the AWS access-key literal with the bare keyword aws_secret_access_key (:1563), which matches secretPatterns.yml's "AWS cred file info" rule (regex: (aws_access_key_id|aws_secret_access_key)) without being a credential value. grep -nE "AKIA|ASIA" over the file returns nothing, and no // nosemgrep suppression remains.
High Security Authentication/authorization checks present N/A Test-only change; no auth surface touched.
High Security Input validation and sanitization N/A Test-only change.
High Security No IDOR — resource ownership validated N/A Test-only change.
High Security No SQL injection (parameterized queries) N/A No SQL in this repo path.
High Correctness Logic is correct, handles edge cases Pass Reviewer traced the full chain: sendBuildLogs (src/percy.js:895-926) → redactSecrets (src/utils.js:748-772) → secretPatterns.yml:59-61. logger.instance.log('ci', 'info', …) genuinely produces a 'ci'-tagged entry: Logger#log (packages/logger/src/logger.js:300-313) calls _record unconditionally, before the shouldLog gate, so the entry is retrievable via query() even though 'ci' is excluded from console output.
High Correctness Error handling is explicit, no swallowed exceptions Pass Spec asserts expectAsync(percy.sendBuildLogs()).toBeResolved() rather than swallowing; no bare try/catch introduced.
High Correctness No race conditions or concurrency issues Pass discovery: { concurrency: 1 }, single awaited call, matching sibling specs.
Medium Testing New code has corresponding tests Pass This PR is the test. Confirmed it fails on regression: with redactSecrets removed from the clilogs branch, aws_secret_access_key survives in sent.clilogs and expect(clilogs).not.toContain(secretish) fails.
Medium Testing Error paths and edge cases tested Pass Not vacuous: expect(sent.clilogs.length).toBeGreaterThan(0) and the cilogs equivalent (:1584-1585) guard both not.toContain assertions against passing against an empty []. Both halves also assert toContain('[REDACTED]'). One optional gap noted as a Low finding below.
Medium Testing Existing tests still pass (no regressions) Pass All 18 package test jobs green on 32af7aa, including Test @percy/core.
Medium Performance No N+1 queries or unbounded data fetching N/A Test-only change.
Medium Performance Long-running tasks use background jobs N/A Test-only change.
Medium Quality Follows existing codebase patterns Pass Setup (new Percy(...), percy.build = { id: 1 }, Pako.ungzip + Buffer.from(..., 'base64') decode of the /logs payload) mirrors the sibling specs in the same describe('sendBuildLogs', …) block.
Medium Quality Changes are focused (single concern) Pass One file, one added spec, no production-source delta.
Low Quality Meaningful names, no dead code Pass secretish, clilogs, cilogs are clear; no dead code.
Low Quality Comments explain why, not what Pass Comments are all rationale (why the keyword rather than a key literal, why logger.instance rather than percy.log, why the length guards exist) and are accurate. Length flagged as a Low nit only.
Low Quality No unnecessary dependencies added Pass No dependency changes; Pako was already imported by this test file.

Test isolation was checked specifically: the env-var mutations (PERCY_TOKEN set, PERCY_CLIENT_ERROR_LOGS deleted) and the reassignment of the shared percy binding match the sibling specs exactly, and are cleaned up by the shared top-level afterEach (packages/core/test/percy.test.js:35-41), which deletes both env vars and calls percy.stop(). No leak.

Findings

All three findings are Low and non-gating.

  • File: packages/core/test/percy.test.js:1519 (also :1486)

  • Severity: Low

  • Reviewer: stack-code-reviewer

  • Issue: Pre-existing, not introduced by this PR. The two neighbouring CI-log specs call percy.log.info('ci_test', {}, true), expecting the third argument to tag the entry as CI. It is discarded: percy.log is a namespace group bound as log.bind(this, 'core', level) (packages/logger/src/logger.js:122-130), so the entry is recorded under 'core' and lands in clilogs, not cilogs. should add CI logs if PERCY_CLIENT_ERROR_LOGS is not present (:1507) therefore does not verify CI-log separation the way its name implies — it passes only because it asserts on the request being made, not on log content. Worth flagging here because this PR's own comment (:1565-1569) documents the same mechanism.

  • Suggestion: Optional fast follow-up: switch those two specs to logger.instance.log('ci', 'info', 'ci_test'), as the new spec does, so a future reader does not copy the no-op percy.log.info(msg, meta, true) idiom.

  • File: packages/core/test/percy.test.js:1555

  • Severity: Low

  • Reviewer: stack-code-reviewer

  • Issue: Nit. The inline commentary is unusually long for a single spec — roughly 14 comment lines across two blocks. The content is valuable and verified accurate, but the Semgrep/nosemgrep-SARIF rationale is repo-process history rather than something needed to read the test.

  • Suggestion: Optionally condense the Semgrep rationale to one line, keeping the two non-obvious parts (why the keyword pattern, and why logger.instance is required to tag a CI entry). Not blocking.

  • File: packages/core/test/percy.test.js:1589

  • Severity: Low

  • Reviewer: stack-code-reviewer

  • Issue: Nit / optional coverage. The spec always sends both halves, so it does not confirm that clilogs redaction is independent of the CI-log toggle. The existing should not add CI logs if PERCY_CLIENT_ERROR_LOGS is false spec (:1474) carries no secret fixture, so no test pairs PERCY_CLIENT_ERROR_LOGS=false with a secret-bearing clilogs entry.

  • Suggestion: Optionally add a short case setting PERCY_CLIENT_ERROR_LOGS=false with a secret in a CLI-side entry, asserting clilogs is still redacted and cilogs is absent. Not required for this regression guard to be effective.

Notes on other signals (non-gating)

  • @Shivanshu-07 approved this PR on 2026-09-03 with no written concerns — nothing to reconcile.
  • Five github-advanced-security[bot] inline comments on this file report detected-aws-access-key-id-value. These are bot findings from earlier pushes, when the spec still used a literal AWS key. The head commit 32af7aa removed that literal, and the flagged line no longer exists — verified by grep. Bot comments are excluded from the human signal and are not gating.
  • ⚠️ The review-thread GraphQL query returned no data, so thread-resolution state was unavailable for this run. On a first run nothing is carried forward, so this had no effect on the outcome; it would only matter for pruning on a later run.

Verdict: PASS — a focused, genuinely-effective regression guard; the three findings are Low nits and optional follow-ups.

@ninadbstack
ninadbstack merged commit f9524b0 into master Sep 7, 2026
50 checks passed
@ninadbstack
ninadbstack deleted the PER-9354-redact-clilogs branch September 7, 2026 11:46
Sign up for free to 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.

3 participants