tirith scan correlates a repository's GitHub Actions workflows and reports one
specific trust break: an untrusted contributor's workflow uploads a build
artifact, and a privileged workflow_run workflow downloads that artifact from
the triggering run and then executes, sources, PATH-mutates, publishes, or
deploys it.
This is the Safe and Bybit class of build-output compromise. The artifact is the trust boundary, and CI is where it is usually forgotten.
No single workflow file can prove a producer-to-consumer chain. The producer and the consumer are different files, and the binding between them is an artifact name plus a run id.
So crates/tirith-core/src/rules/workflow_artifacts.rs reduces each workflow to
a bounded WorkflowModel and analyze_repository correlates the models.
crates/tirith-core/src/scan.rs owns the plumbing; the analyzer itself reads
neither the filesystem nor the network, and every function in it is total, so a
malformed or hostile workflow yields no findings rather than a panic.
Consequence an operator must know: the post-pass runs only for a directory
scan, and "directory scan" is not the CLI alone. It lives inside scan::scan
(scan.rs:254, invoking it at :436), so it runs for tirith scan
(crates/tirith/src/cli/scan.rs:248), the tirith_scan_directory MCP tool
(crates/tirith-core/src/mcp/tools.rs:742), and both MCP scan resources
(crates/tirith-core/src/mcp/resources.rs:153 and :244). An agent gating on
tirith_scan_directory gets the chain findings and the downgraded severity just
as the CLI does.
Per-file analysis never runs it: tirith check on a single workflow file, the
tirith_scan_file MCP tool via scan_single_file_guarded, and the LSP.
workflow_artifact_poisoning (High) fires only for the complete proven chain:
fork/PR-reachable untrusted producer
-> uploads an artifact under a statically known name
-> privileged `workflow_run` consumer bound to the TRIGGERING run
-> matching artifact identity
-> execute / source / PATH-mutation / publish / deploy sink
Anything less is represented as INCOMPLETENESS, never guessed. Each of these
records an unresolved note instead of a finding: an expression, a wildcard, a
reusable workflow, a composite action, a matrix job, an if: condition, a
download mechanism or post-download command outside the modelled tables, an
artifact re-upload hop, a digest comparison whose expected value cannot be
placed, and a shell the analyzer cannot resolve.
Containment is what proves an execute, source, or PATH-mutation sink touched the
artifact's bytes, so the extraction directory has to be the REAL one.
actions/download-artifact, dawidd6/action-download-artifact, and
gh run download each fan every artifact of the run into its own
<path>/<artifact-name>/ subdirectory unless they are given exactly one
artifact name, and the model follows that rule rather than assuming a flat
extraction.
An artifact re-upload hop is not followed. A workflow_run workflow that
downloads a cross-run artifact and then uploads one of its own is republishing
bytes of untrusted provenance under a NEW identity, and following that would
need taint propagation this pass does not do. A consumer bound to such a relay
is therefore bound to an artifact of unknown provenance, not of known-clean
provenance.
A digest comparison whose expected value came from INSIDE the downloaded tree suppresses nothing. Naming it in the evidence is what tells the operator their check is self-referential rather than absent.
workflow_run_trigger is the pre-existing presence-level finding: this
repository has a privileged workflow_run workflow at all. It is High.
The post-pass may lower it to Medium, but only for a consumer it had COMPLETE visibility into and proved no chain for. Completeness is destroyed by any exhausted bound, any workflow that panicked, and any workflow that did not parse, so a bounded scan degrades to "not proven either way" rather than to "clean". A workflow that could not be parsed could be an untrusted producer, so its mere presence removes the right to say no chain exists.
The lowering happens only in the post-pass, never in rules::cifile, because
the four surfaces listed above cannot run the post-pass and must keep the
original severity. A downgrade there would have weakened tirith check, the MCP
scan tool, and the LSP.
| Bound | Value |
|---|---|
| workflows modelled per repository | 256 |
| aggregate YAML source retained | 32 MiB |
| aggregate steps modelled | 4096 |
segments per run: body |
512 |
| words per segment | 256 |
| bytes per word | 4096 |
A dropped workflow gets its own typed coverage gap, which becomes an
analysis_incomplete finding through the normal driver path, plus one stderr
note per scan rather than one per workflow. A monorepo therefore cannot turn the
bound into thousands of diagnostic lines while losing nothing machine-readable.
- Not enforcement. This is a scanner finding. Tirith does not stop a
workflow from running, does not talk to the GitHub API, and does not gate a
merge. A CI job can fail on the finding through
tirith scan --ciif an operator wires it that way. - Not a general Actions linter. It answers one question about artifact
provenance. Pinning, permissions, and secret exposure are covered by the
existing
cifilerules, separately. - Not taint propagation. One hop is modelled. A relay is treated as unknown provenance rather than followed.
- Enforcement coverage, the per-capability ledger
- Threat model