Skip to content

Track credential handoffs through step outputs in secret-leak analysis - #71850

Merged
pelikhan merged 6 commits into
mainfrom
copilot/fix-secret-leak-handling
Oct 25, 2026
Merged

pelikhan merged 6 commits into
mainfrom
copilot/fix-secret-leak-handling

Conversation

Copilot AI commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

secret-leak reports private step-output credential handoffs as disclosure before evaluating their consumers. This shared root cause blocked 62 pinned gh-cao workflows; those occurrences do not establish 62 credential leaks.

  • Transport: Treat named, modeled same-job outputs as transport while preserving taint through shell writes, JavaScript core.setOutput, and GitHub Script returns.
  • Disclosure boundaries: Retain downstream log, summary, artifact, publication, network, and exposed-output checks. Track command-file readbacks without carrying their contents across steps.
  • Conservative fallback: Keep ambiguous names, unresolved identities, multi-record writes, and unmatched fast-mode JavaScript entries conservative. Preserve custom rules’ output policies.
  • Regressions and documentation: Cover shared-import credential resolution, downstream disclosures, cross-language handoffs, and fast-mode propagation.

The intended authentication handoff requires no suppression:

- id: credential
  env:
    TOKEN: ${{ secrets.READ_TOKEN }}
  run: echo "credential=$TOKEN" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v6
  with:
    persist-credentials: false
    token: ${{ steps.credential.outputs.credential }}

Compatibility limitation: The requested pinned 74-workflow rerun remains blocked by organization SAML authorization (403); no updated corpus pass counts are available.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix secret leak linter handling for credential handoffs Oct 25, 2026
Copilot AI requested a review from pelikhan October 25, 2026 08:02
@pelikhan
pelikhan marked this pull request as ready for review October 25, 2026 12:03
Copilot AI max review requested due to automatic review settings October 25, 2026 12:03
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ PR Code Quality Reviewer completed the code quality review.

🔎 Code quality review by PR Code Quality Reviewer

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ Test Quality Sentinel completed test quality analysis.

Test Quality Sentinel

Outcome: reported_no_action

The agent completed and reported no action. A no-op output alone does not verify that all required work was completed or that publication was possible.

Agent messages are omitted from this comment. The category uses structured run status, not interpretation of agent prose.

Generated from Test Quality Sentinel · sonnet50 · ~16 AIC · ⌖ ~16 AIC · ◇ ~7 AIC · ⊞ 12.7K · ◷

🧪 Test quality analysis by Test Quality Sentinel

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ Ponytail Reviewer completed successfully!

Generated by Ponytail Reviewer for #71850

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ Design Decision Gate 🏗️ completed the design decision gate check. See the comment below for the result and any generated ADR draft.

Commit pushed: ec34e31

🏗️ ADR gate enforced by Design Decision Gate 🏗️

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One opportunity to keep fast-mode reconciliation conservative and smaller.

net: -17 lines possible.

Generated by ✂️ Ponytail Reviewer for #71850 · codex · gpt56 · ~13 AIC · ⌖ ~6 AIC · ⊞ 14.8K
Comment /ponytail to run again

return true
}
if fastSiblingSite(compiled.Path, authored.Path, target, from) {
return true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

L68-91: shrink: positional sibling-site matching plus its path parser. Keep the exact source-to-compiled mapping above; unmatched entries already take the documented conservative fallback, removing 17 lines.

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.

I left this separate size optimization unchanged; the requested code changes were limited to the eight correctness findings in the linked review.

@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (847 new lines in pkg/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/71850-treat-modeled-step-outputs-as-taint-transport-in-secret-leak.md — review and complete it before merging.

Decision captured in the draft: For secret-leak only, a named, step-id-resolved step-output write with a modeled consumer scope is treated as private same-job transport (taint carried via steps.<id>.outputs.<name>) rather than a disclosure sink. Everything else keeps conservative findings or coverage gaps (see pkg/workflow/taint/step_outputs.go, shell.go, javascript_workflow_fast.go).

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — verify deciders (marked [TODO: verify]), refine the rationale, and confirm the alternatives (suppressions, blanket output exemption, name-based allowlist)
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-71850: Treat Modeled Same-Job Step Outputs as Taint Transport in Secret-Leak Analysis

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number.

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · pi · opus55 · ~29 AIC · ⌖ ~41 AIC · ⊞ 1.9K · ◷
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Request changes

The new output transport analysis has a false-negative path: dynamic shell expansions can write multiple output records, while the analyzer binds taint only to the first statically inferred name. A secret can therefore reach another named step output without being reported. The inline comment includes the required conservative fix and regression.

🔎 Code quality review by PR Code Quality Reviewer · copilot · gpt50 · ~8 AIC · ⌖ ~1 AIC · ⊞ 1.6K
Comment /review to run again

commandWord, _ := sliceAt(call.Args, 0)
first, _ := sliceAt(call.Args, 1)
name := literalShellWord(commandWord)
if name == "echo" && len(call.Args) == 2 && singleOutputWord(first) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A dynamic expansion in the value written by echo can contain additional newline-delimited output records, but this path still binds the taint only to the statically inferred name. That lets a secret written as token=...\nother=... be consumed through steps.producer.outputs.other without propagation, producing a false negative.

💡 Preserve conservative tracking for dynamic records

singleOutputWord only rejects newlines in static text; it does not account for parameter or command substitutions. If the payload contains any expansion, bind the write to the wildcard output key (or emit a coverage gap) unless the record structure is proven single-line. Add a regression covering a variable containing a newline and a downstream consumer of the second output name.

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.

Fixed in fd9f316: dynamic expansions add wildcard output bindings, and the regression verifies a newline-delimited second output reaches its consumer.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /codebase-design and /tdd (via fallback heuristic — pr-triage ran successfully as bug_fix, confirming shell.go, step_outputs.go, javascript_workflow_fast.go as high-impact).

This is a well-scoped fix: the transport-vs-disclosure distinction is isolated behind transportOutput, is gated strictly (secret-leak only, workflowContext required, named+resolved+modeled), and is backed by an extensive regression suite (step_outputs_test.go, command_output_contents_test.go) covering shell, JavaScript, cross-language, fast-mode, and conservative-fallback paths. The compiler-lint integration test confirms end-to-end behavior for the motivating scenario.

📋 Key Themes & Highlights

Key Themes

  • Readability of the policy gate (step_outputs.go): the core decision combining rule ID, workflow context, name resolution, and transport modeling is a single 5-term || — worth a named boolean for future maintainers.
  • Minor shadowing: copy as a local variable in javascript_workflow.go shadows the Go builtin; a descriptive name would help.
  • Thin direct coverage of path-matching arithmetic: fastStepLocation/fastSiblingSite in javascript_workflow_fast.go do index-offset string parsing that's only exercised indirectly through end-to-end fast-mode tests; a few focused unit tests would pin the contract down before the path format is touched again.

Positive Highlights

  • ✅ Conservative-by-default design: unresolved names/IDs, compound writes, and unmatched fast-mode entries all retain findings/gaps rather than silently suppressing them.
  • ✅ Clear separation: custom (non-secret-leak) rules keep seeing Output findings for transported values, preserving existing rule semantics (TestStepOutputJobBoundaryAndGenericRule).
  • ✅ Docs updated in lockstep (workflow-linters.md) explaining the new transport semantics and its limits.

No blocking issues — the three inline notes above are maintainability/test-coverage suggestions, not correctness concerns.

@copilot please address the review comments above.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · copilot · sonnet50 · ~106 AIC · ⌖ ~16 AIC · ⊞ 10.3K
Comment /matt to run again

j.files["$GITHUB_OUTPUT"] = union(j.files["$GITHUB_OUTPUT"], sources)
transport := j.a.stepOutput
if transport != nil && j.options != nil && j.options.fastMatched != nil && j.options.fastEntries == nil && len(j.options.fastSites[j.input.Name]) == 0 {
copy := *transport

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] Shadowing the builtin copy identifier as a local variable name hurts readability and could trip up future readers expecting the builtin.

💡 Suggested rename
transport := j.a.stepOutput
if transport != nil && j.options != nil && j.options.fastMatched != nil && j.options.fastEntries == nil && len(j.options.fastSites[j.input.Name]) == 0 {
    unmodeled := *transport
    unmodeled.modeled = false
    transport = &unmodeled
    ...
}

A name like unmodeled or fallback also documents intent better than copy.

@copilot please address this.

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.

Addressed in fd9f316: the transport copy was removed in favor of updating the shared per-step transport state.

}
return false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] fastSiblingSite/fastStepLocation do subtle offset arithmetic (ci-ti == ai-fi) to correlate compiled vs. authored step indices, but there is no direct unit test for these helpers — coverage is only indirect via TestFastStepOutputKeepsEveryCompiledConsumer.

💡 Suggested tests

Add table-driven tests directly against fastStepLocation and fastSiblingSite covering: no steps[ substring, malformed index, nested steps[ (e.g. matrix/reusable workflow paths), and mismatched prefixes. This pins down the parsing contract before someone refactors the string-based path format and silently breaks matching.

@copilot please address this.

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.

I did not add the separate path-parser table tests because the requested code scope was limited to the linked correctness review; the fast-mode regressions cover the affected per-occurrence matching behavior.

Comment thread pkg/workflow/taint/step_outputs.go Outdated
if len(sources) > 0 && transport != nil && (!named || !resolved) {
a.gapAt(site.Path, site.Line, "step output name or step id is unresolved; downstream output tracking is conservative")
}
if a.rule.ID != "secret-leak" || !a.workflowContext || !named || !resolved || !transport.modeled {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] The final gate combines five conditions in one || expression, mixing rule identity, context, and two independent name/transport-resolution checks. This is the crux of the bug fix (deciding transport vs. disclosure) so its readability matters more than most lines in this diff.

💡 Suggested refactor
privateTransport := a.rule.ID == "secret-leak" && a.workflowContext && named && resolved && transport.modeled
if !privateTransport {
    a.emit(site, sources)
}

Naming the condition (privateTransport) documents the policy this function encodes and makes future changes (e.g. adding another rule ID) safer to reason about.

@copilot please address this.

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.

I left this readability-only refactor unchanged to keep code changes within the linked review's requested scope.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Output-record inference and fast-mode verification can suppress disclosure findings without preserving downstream secret taint.

8 open issues
What changed in this PR

This PR updates gh-aw’s secret-leak analysis to distinguish private credential handoffs from disclosure while preserving taint for downstream checks.

Changes:

  • Model named shell and JavaScript step outputs as same-job transport.
  • Replay authored JavaScript through compiled consumers in fast mode and track command-file readbacks.
  • Add transport, disclosure, and shared-import regressions, plus documentation.
File Description
pkg/​workflow/​taint/​workflow.go Establishes workflow transport context and integrates fast-mode analysis.
pkg/​workflow/​taint/​taint.go Adds analyzer state for step-output transport.
pkg/​workflow/​taint/​taint_test.go Updates expected findings and conservative coverage gaps.
pkg/​workflow/​taint/​step_outputs.go Centralizes output bindings and transport exemptions.
pkg/​workflow/​taint/​step_outputs_test.go Covers handoffs, downstream disclosures, and unresolved boundaries.
pkg/​workflow/​taint/​shell.go Adds output-record inference and command-file tracking.
pkg/​workflow/​taint/​safe_output_policy_test.go Updates expectations for unresolved output identities.
pkg/​workflow/​taint/​javascript.go Adds fast-mode entry-matching state.
pkg/​workflow/​taint/​javascript_workflow.go Connects JavaScript outputs and returns to workflow bindings.
pkg/​workflow/​taint/​javascript_workflow_fast.go Replays authored entries and reconciles findings.
pkg/​workflow/​taint/​javascript_fast_test.go Updates the expected compiled diagnostic location.
pkg/​workflow/​taint/​javascript_eval.go Filters and records fast-mode entries.
pkg/​workflow/​taint/​javascript_calls.go Models output transport and command-file reads.
pkg/​workflow/​taint/​command_output_contents_test.go Covers readback disclosures and step-local file lifetimes.
pkg/​workflow/​taint/​authored.go Separates standalone and workflow-aware authored analysis.
pkg/​workflow/​compiler_lint_test.go Adds shared-import credential-resolver regressions.
docs/​src/​content/​docs/​reference/​workflow-linters.md Documents transport semantics and conservative fallbacks.

Review effort: Max

// Authored exemptions require a successful compiled-entry match. Compiler
// normalization or unavailable generated wrappers must remain conservative.
options.fastEntries = nil
scripts, scriptErr := analyzeAuthoredJavaScript(ctx, document, trackedRule, true)

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.

Fixed in fd9f316: unmatched authored JavaScript provenance now marks step-output transport unmodeled when tainted stdout/files flow into shell writes, retaining the Output finding.

Comment on lines +101 to +102
if _, exists := j.options.fastSites[j.input.Name]; exists {
return

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.

Fixed in fd9f316: fast-mode verification is now tracked per source occurrence, and each javascript instance uses its own verification result when transporting outputs.

Comment thread pkg/workflow/taint/shell.go Resolved
commandWord, _ := sliceAt(call.Args, 0)
first, _ := sliceAt(call.Args, 1)
name := literalShellWord(commandWord)
if name == "echo" && len(call.Args) == 2 && singleOutputWord(first) {

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.

Fixed in fd9f316: backslash-bearing echo words are no longer inferred as a single named output, so escaped record boundaries retain conservative findings and wildcard propagation.

Comment thread pkg/workflow/taint/shell.go Resolved
Comment thread pkg/workflow/taint/shell.go Outdated
}

func singleOutputWord(word *syntax.Word) bool {
if strings.ContainsAny(staticWord(word), "\r\n") {

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.

Fixed in fd9f316: dynamic shell expansions now add wildcard output bindings, with a regression for a variable containing a second newline-delimited output record.

Comment thread pkg/workflow/taint/shell.go Outdated
s.bindings[key] = union(s.bindings[key], sources)
}
s.files[target] = union(s.files[target], sources)
s.a.transportOutput(site, name, sources, s.a.stepOutput)

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.

Fixed in fd9f316: shell and JavaScript writes share per-step record state; unresolved or unterminated records bind wildcard taint and preserve Output findings.

Comment thread pkg/workflow/taint/workflow.go Outdated
return err
}
previous := a.stepOutput
a.stepOutput = &stepOutputTransport{id: id, bindings: bindings, modeled: a.workflowContext}

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.

Fixed in fd9f316: job environment.url is evaluated after steps against output bindings and emitted as a Display sink.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot Fix the code for all comments in this review thread.

When a review comment includes a suggested change, apply the suggestion exactly.

Do not make changes beyond what is described in the linked review thread.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

@copilot run pr-finisher skill

Ran the PR finisher pass. The linked correctness findings are fixed in fd9f316; local validation passed, and the remaining review/merge blockers are noted in the hand-off.

@copilot Fix the code for all comments in this review thread.

When a review comment includes a suggested change, apply the s...

Implemented the eight linked correctness fixes in fd9f316 and replied to each finding. Local formatting, lint, build, impacted tests, and workflow checks passed; CodeQL was skipped because the database is too large.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
@pelikhan
pelikhan merged commit cb5c114 into main Oct 25, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

3 participants