Skip to content

fix(cla): declare workflow_call.secrets so callers can stop using secrets: inherit - #22

Merged
ywatanabe1989 merged 2 commits into
mainfrom
fix/declare-workflow-call-secrets
Jul 29, 2026
Merged

fix(cla): declare workflow_call.secrets so callers can stop using secrets: inherit#22
ywatanabe1989 merged 2 commits into
mainfrom
fix/declare-workflow-call-secrets

Conversation

@ywatanabe1989

Copy link
Copy Markdown
Contributor

What this changes

Declares workflow_call.secrets for the one secret this workflow actually reads, so callers can stop passing secrets: inherit.

on:
  workflow_call:
    inputs: 
    secrets:
      GH_PERSONAL_ACCESS_TOKEN:
        required: true

Backward compatible. secrets: inherit keeps working, so no caller breaks and repos migrate when they choose. secrets.GITHUB_TOKEN needs no declaration — it is provided to called workflows automatically.

Why

Every migrated repo's caller looks like this, because this workflow reads secrets.GH_PERSONAL_ACCESS_TOKEN without declaring it:

on:
  issue_comment:
  pull_request_target:        # ← startable by anyone, runs against the BASE repo
jobs:
  call:
    uses: scitex-ai/.github/.github/workflows/cla.yml@main
    secrets: inherit          # ← forwards EVERY secret the calling repo holds

inherit is the only option available to a caller today. It hands the full secret set of the calling repo to a job an outsider can start by opening a PR or typing a comment. Narrowing to the single secret the workflow needs cuts the blast radius without changing what the workflow can do.

What this does NOT change

runs-on is untouched — both jobs still target [self-hosted, Linux, X64, spartan-cpu]. Whether an attacker-triggerable, credential-bearing job should run on the shared box is a separate decision, it belongs to whoever owns the no-hosted-runners directive, and it is deliberately not bundled here. This PR is the half that improves things under either outcome.

Severity, bounded

Measured by scitex-app, not by me, and labelled as theirs: no actions/checkout of the PR head anywhere in this workflow, and the sole third-party action is SHA-pinned. No code-injection path was found. What remains is attacker-triggerable execution on a persistent shared runner with a fleet PAT in the job environment — a hardening concern, not an active compromise.

Explicitly unverified by anyone: the pinned action's contents, and whether a fork PR actually reaches these jobs.

Verification

  • Branch YAML parses; workflow_call.secrets resolves to {'GH_PERSONAL_ACCESS_TOKEN': {'required': True}}
  • inputs unchanged: default_branch, owner_allowlist, owner_login
  • Both jobs' runs-on confirmed unchanged

Context

scitex-dev's PS-224 rule was telling repos to move workflows like this one onto a registered (Spartan) destination — advice that is backwards for this shape. That scope bug is fixed separately in scitex-dev#475. This PR is the org-side half: it makes the safer caller configuration possible, which it currently is not.

Not merging this myself despite having write access — the template is sac's and the runner question is unresolved.

…crets: inherit`

Every migrated caller passes `secrets: inherit` because this workflow reads
`secrets.GH_PERSONAL_ACCESS_TOKEN` without declaring it. `inherit` forwards
EVERY secret the calling repo holds into a job started by `pull_request_target`
or `issue_comment` -- events an outsider can trigger with no write access.

Declaring the one secret this workflow actually needs lets a caller narrow to:

    secrets:
      GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}

BACKWARD COMPATIBLE: `secrets: inherit` continues to work, so no caller
breaks and repos migrate when they choose.

`secrets.GITHUB_TOKEN` needs no declaration -- it is provided to called
workflows automatically.

This does NOT touch `runs-on`. Whether this workflow should run on
spartan-cpu is a separate decision and belongs to whoever owns the
no-hosted-runners directive.
Requested by sac on the PR. A maintainer migrating to this template sees
`spartan-cpu` and no reason to question it; sac's own cla.yml cannot be
'fixed' by a well-meaning agent without reading why first. This gives the
template the same protection.

Records the four facts that only bite in combination, the measured severity
bound (no injection path found; hardening, not compromise), what remains
unverified, and that sac's own repo refused this shape with a written
PS-224 exemption calling the move a REGRESSION.

No behaviour change - comments only. runs-on unchanged.
@ywatanabe1989
ywatanabe1989 merged commit ecb0345 into main Jul 29, 2026
1 check passed
ywatanabe1989 added a commit to scitex-ai/scitex-dev that referenced this pull request Jul 29, 2026
PS-224's new caller-side check found a TRUE finding in this repo and this
commit fixes the workflow rather than the rule.

The org reusable now declares `workflow_call.secrets`
(scitex-ai/.github#22, commit ecb0345b), so a caller can finally name the
single secret the callee reads. Before that, `secrets: inherit` was the
only option available.

Two coupled changes, because they cannot be separated: passing a named
secret to a callee that does not declare it is an error, so the pinned
callee ref moves 399f19be -> ecb0345b in the same commit. The file's own
policy is to pin the SHA and take reusable updates deliberately; this is
one such deliberate update, and the reason is recorded in the file.

Measured on this repo, `secrets: inherit` was forwarding TWO secrets:

  GH_PERSONAL_ACCESS_TOKEN      the callee actually reads it
  CLAUDE_CODE_CREDENTIALS_JSON  the fleet Claude credential, no CLA use

The second had no business being in the environment of a job an
unauthenticated stranger can start via `pull_request_target` or
`issue_comment`. Org-level secret list is empty, so that count is
per-repo -- two is this repo's number, not a fleet number.

Verified, including the control:

  PS-224 findings on this repo             1 -> 0
  control, same file reverted to `inherit`  STILL FIRES
  narrowed shape                            does not fire
  triggers still detected                   issue_comment,
                                            pull_request_target

The control is the point. A rule going quiet after an edit is
indistinguishable from a blinded detector unless you show the
unnarrowed shape still trips it.

What this does NOT fix, stated in the file so a later reader does not
mistake a quiet rule for a safe job: the callee still runs on the
persistent shared runner with the PAT in the job environment. Narrowing
shrinks blast radius; it does not remove credential adjacency, and
neither file shows that adjacency on its own. PS-224 reads one file at a
time, so the residual is tracked as a rule gap.
ywatanabe1989 added a commit to scitex-ai/scitex-dev that referenced this pull request Jul 29, 2026
… secret jobs (#475)

* fix(audit): PS-224 told you to move attacker-triggerable secret jobs onto the credential box

Sixteen rule modules read `.github/workflows/`. Zero modelled the TRIGGER:
`pull_request_target`, `workflow_run` and `issue_comment` appeared nowhere
in the audit corpus.

The corpus read workflows along two axes — how a secret is NAMED (PS-168,
which blesses `GH_PERSONAL_ACCESS_TOKEN` as canonical) and where a job RUNS
(PS-224). Trigger is the axis that decides whether either matters, and it
was unrepresented. Two rules, each correct alone, combined into: "that
correctly-named fleet PAT — please run it on the shared box."

Measured: 619 workflow files across 112 repos, 434 carrying a hosted label,
70 of those also attacker-triggerable AND secret-bearing.

Adds `_workflow_exposure.py`. Handles the YAML gotcha that hides triggers
from every naive reader: bare `on:` parses as the BOOLEAN True, so
`doc.get("on")` silently returns nothing for most real workflows —
knowledge `_check_hosted_runners` already had and had never shared.
Secret detection is a text scan of the serialized job rather than a walk of
known secret-bearing keys, because a secret reaches a step via `env:`,
`with:`, an inline `run:` or a `uses:` input, and enumerating those sites is
the kind of incomplete list this module exists to stop relying on.

PS-224 now branches. Unexposed jobs keep the original remedy verbatim.
Exposed jobs are told DO NOT RE-POINT, with the triggering event and the
specific secret named.

The exposed branch withholds a remedy rather than choosing one. The safe
options are to keep the job hosted — an exception to the no-hosted-runners
directive, which states no exceptions — or to stop needing the credential,
which changes what the workflow can do. Neither is the rule's to pick, so it
reports the fact, withholds the remedy, and escalates. A rule that cannot
name a safe fix should state that plainly rather than name an unsafe one.

Severity is rule-keyed, so PS-224 cannot emit this class as a warning; the
gate stays red for these until the directive owner decides.

The positive control is the test that matters: not that the new text is
present, but that "re-point the job" is ABSENT. A fix that appended a
warning while leaving the original remedy would pass a presence-only test
and still ship the advice that caused the finding.

* docs(audit): state the cross-file exposure bound this reader cannot see

A reusable workflow declares `on: workflow_call` while the
`pull_request_target` reaching it lives in the CALLER. Read alone the
called file looks safe; the calling file has no `runs-on` to flag. The
exposure is real and neither file shows it.

Measured on this repo's own cla.yml, whose single `uses:` job reports
exposed=False. Closing it needs a cross-file call graph. Until then the
bound is stated, with a test pinning current behaviour so a future fix has
an anchor: absence of exposure means 'not visible in this file', never
'not exposed'.

Found by scitex-app while checking their own cla.yml against the fix.

* fix(audit): flag the delegating caller — the blind spot is the common case, not an edge

The previous commit documented the cross-file gap as a bound. scitex-app
checked whether it was real and found it already live on the ORG TEMPLATE
every migrated repo calls. Verified independently before acting.

CALLER (scitex-app@main .github/workflows/cla.yml):
  on: issue_comment, pull_request_target      <- attacker-triggerable
  jobs.call:
    uses: scitex-ai/.github/.github/workflows/cla.yml@main
    secrets: inherit                           <- fleet PAT propagates
  no `runs-on` anywhere -> PS-224 never looked at this job

CALLEE (scitex-ai/.github, fetched):
  on: workflow_call                            <- looks safe read alone
  line 49/68: runs-on: [self-hosted, Linux, X64, spartan-cpu]
  line 76: PERSONAL_ACCESS_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}

So the migration has ALREADY produced the end-state this rule must never
recommend, reached by another route, invisible from either file alone. A
per-file reader returns "not exposed" for every repo in the fleet on the one
workflow where the answer is yes — wrong in the single direction that
matters. The bound was describing the common case.

`secrets: inherit` on an attacker-triggerable caller is decidable from the
CALLER ALONE: it means "delegate execution with every secret this repo
holds" without naming one. So this needs no call graph and ignores
`runs-on` entirely — the caller has none, which is exactly how the job
stayed invisible.

PS-224 previously skipped every job without `runs-on`. It now flags this
shape, and the docstring's "the one case that is NOT a violation" is
corrected to match.

Severity bounded, not assumed: scitex-app checked for a code-injection path
and found none (no checkout of PR head in the callee; the sole third-party
action is SHA-pinned). What remains is attacker-triggerable execution on a
persistent shared runner with a fleet PAT in the environment — a hardening
concern. The finding text says to verify where the callee runs rather than
asserting a compromise.

Fit under the 512-line cap by collapsing two imports into a module import,
not by using the refactor escape hatch.

19 tests. The negative controls matter as much as the positive: a
delegating caller WITHOUT an attacker-triggerable trigger is not flagged
(`secrets: inherit` alone is not the defect), and a non-delegating job is
not flagged as delegating.

Reported by scitex-app; caller-side signal was their suggestion.

* fix(ci): narrow CLA caller to one secret, unblocked by org template

PS-224's new caller-side check found a TRUE finding in this repo and this
commit fixes the workflow rather than the rule.

The org reusable now declares `workflow_call.secrets`
(scitex-ai/.github#22, commit ecb0345b), so a caller can finally name the
single secret the callee reads. Before that, `secrets: inherit` was the
only option available.

Two coupled changes, because they cannot be separated: passing a named
secret to a callee that does not declare it is an error, so the pinned
callee ref moves 399f19be -> ecb0345b in the same commit. The file's own
policy is to pin the SHA and take reusable updates deliberately; this is
one such deliberate update, and the reason is recorded in the file.

Measured on this repo, `secrets: inherit` was forwarding TWO secrets:

  GH_PERSONAL_ACCESS_TOKEN      the callee actually reads it
  CLAUDE_CODE_CREDENTIALS_JSON  the fleet Claude credential, no CLA use

The second had no business being in the environment of a job an
unauthenticated stranger can start via `pull_request_target` or
`issue_comment`. Org-level secret list is empty, so that count is
per-repo -- two is this repo's number, not a fleet number.

Verified, including the control:

  PS-224 findings on this repo             1 -> 0
  control, same file reverted to `inherit`  STILL FIRES
  narrowed shape                            does not fire
  triggers still detected                   issue_comment,
                                            pull_request_target

The control is the point. A rule going quiet after an edit is
indistinguishable from a blinded detector unless you show the
unnarrowed shape still trips it.

What this does NOT fix, stated in the file so a later reader does not
mistake a quiet rule for a safe job: the callee still runs on the
persistent shared runner with the PAT in the job environment. Narrowing
shrinks blast radius; it does not remove credential adjacency, and
neither file shows that adjacency on its own. PS-224 reads one file at a
time, so the residual is tracked as a rule gap.
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.

1 participant