ci(publish): skip crates already on crates.io so a run is resumable - #35
Conversation
Publishing three crates is not atomic. An earlier one uploads, a later one fails, and the tag has half-published — which is exactly what v0.4.7 did: `cargo publish` rejected `verify-trust` because it takes `trql-client` by `git` with no version requirement, after `vgi-core` 0.4.7 had already gone up. crates.io now holds vgi-core 0.4.7 against verify-trust and did-git-sign 0.4.6. Before this change there was no way back. Re-running the tag dies on "crate version is already uploaded" for the crate that did land, so finishing the release meant bumping every crate to a version none of them needed, purely to get past a name that was already correct. Each crate is now checked against the sparse index for its workspace version first and skipped if it is there, so a re-run finishes whatever the failed run started. The check reads index.crates.io directly rather than parsing cargo's error text, which is not a stable interface. A 404 — a name that has never been published — is a clean "not published" and falls through to publishing, so a brand-new crate is unaffected. Verified against the live index: vgi-core 0.4.7 reports as already published while verify-trust and did-git-sign 0.4.7 do not, which is precisely the resume this is for. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review2 AI-confirmed issues, 2 findings need a human to review/validate. Mandatory to check: 🔒 Security Code Review Report Details🛡️ Security Code Review Report — PR #35
🗺️ Scan CoverageModules scanned: 1 · with findings: 1 · files: 1 · findings: 6
Executive Summary
🔒 Security IssuesConfirmed Vulnerabilities (2)🟡 Publish decision based on unauthenticated crates.io sparse-index query without integrity verification
🧠 AI Triage:
📝 Description: The workflow decides whether to skip or perform 🌱 Root Cause: The security-relevant control decision (skip vs. publish) relies entirely on trusting the content returned by curl from a third-party endpoint; a network-level MITM, DNS compromise, or compromised CDN edge could return a fabricated index entry. 🔎 Evidence: 🎯 Attack Scenario: An attacker capable of intercepting or spoofing responses from index.crates.io (e.g., DNS poisoning, compromised network path, or a malicious proxy in the CI runner environment) could return a fabricated 'already published' response for a legitimate crate/version, causing the workflow to silently skip publishing a fixed/patched release, or conversely fabricate a 'not published' response to trigger a duplicate/conflicting publish attempt.
|
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | .github/workflows/publish.yml:38 |
| Finding ID | github_pr-0ddbfd51c18e |
| OWASP | A08:2021 - Software and Data Integrity Failures |
| CVSS 4.0 | 5.5 |
| Exploit Maturity | conceptual |
| Detection Source | mcp_semgrep |
Summary: GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-gi — 3 occurrence(s): publish.yml:38, publish.yml:40, publish.yml:46
📝 Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-gi
🌱 Root Cause: Github Actions Mutable Action Tag
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Priority: Short-term
Github Actions Mutable Action Tag: GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-gi
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 85%
- AI Validation Evidence: EVIDENCE FOUND: workflow uses
uses: actions/checkout@v7,uses: dtolnay/rust-toolchain@stable, anduses: rust-lang/crates-io-auth-action@v1— these are tag/branch references, not pinned to commit SHAs. EVIDENCE NOT FOUND: the finding claims '3 occurrences' at line 38 specifically but line 38 in the provided source corresponds touses: dtolnay/rust-toolchain@stable; exact occurrence-by-occurrence mapping to line numbers not fully verifiable from provided evidence. CHANGED VS PRE-EXISTING:- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
⚪ Cross-Crate Version Consistency Not Validated Pre-Publish (Non-Atomic Multi-Crate Release)
| Field | Detail |
|---|---|
| Severity | INFORMATIONAL |
| Location | .github/workflows/publish.yml:44 |
| Finding ID | github_pr-4f1cb779b984 |
| CWE | CWE-1357, CWE-829 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
| MITRE ATT&CK | T1195.001 - Compromise Software Dependencies and Development Tools |
| CAPEC | CAPEC-538 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | poc |
| Detection Source | skill_scan |
Summary: The publish workflow's per-crate loop lacks a pre-flight check ensuring vgi-core, verify-trust, and did-git-sign will resolve to mutually consistent, pinned versions before any of the three is uploaded to crates.io, allowing a repeat of a previously documented version-mismatch incident.
🧪 Proof of Concept:
There is no step prior to this loop that validates cross-crate dependency version consistency (e.g., checking Cargo.toml manifests for unpinned git dependencies among the three crates). The loop simply attempts to publish each crate in order and relies on cargo publish failing individually if there's a problem, by which point an earlier crate may already be live.
for crate in vgi-core verify-trust did-git-sign; do
version=$(cargo metadata --no-deps --format-version 1 \
| jq -r --arg n "$crate" '.packages[] | select(.name == $n) | .version')
if [ -z "$version" ]; then
echo "::error::${crate} is not a member of this workspace"
exit 1
fi
if already_published "$crate" "$version"; then
echo "::notice::${crate} ${version} is already on crates.io — skipping"
continue
fi
echo "::group::publish ${crate} ${version}"
cargo publish -p "${crate}" --locked
echo "::endgroup::"
done
Vulnerable lines: 44, 61
🔎 Evidence: .github/workflows/publish.yml:44
for crate in vgi-core verify-trust did-git-sign; do
version=$(cargo metadata --no-deps --format-version 1 | jq -r --arg n "$crate" '.packages[] | select(.name == $n) | .version')
...
cargo publish -p "${crate}" --locked
done
🧭 Reachability:
- Network exposure: public
- Auth barrier: none
- Attack path: EP-001 (tag push) → for-loop over crate list (line 44) → cargo metadata version lookup → cargo publish per crate (line 60) with no cross-crate dependency consistency check beforehand
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | high |
| Public exploit | None known |
| Environment | production |
Attack scenario: A careless or malicious contributor introduces an unpinned git dependency between the three workspace crates; when a new tag is pushed, the sequential non-atomic publish loop can succeed for an earlier crate and fail for a later one, leaving crates.io holding mismatched versions — exactly as previously occurred with vgi-core 0.4.7.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 90%
- AI Validation Evidence: EVIDENCE FOUND: the loop
for crate in vgi-core verify-trust did-git-sign; do ... cargo publish -p "${crate}" --locked; donehas no pre-flight step verifying cross-crate dependency version consistency before any publish call, as described. The workflow's own comment acknowledges this exact risk: 'Publishing is not atomic across three crates — an earlier one uploads, a later one can fail... which is how v0.4.7 left vgi-core 0.4.7 published against verify-trust/did-git-sign 0.4.6.' EVIDENCE NOT F- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
Generated by Agentic Sec — AI Security Validation Agent
This report includes full scan data + AI validation evidence. Feed to engineering copilots for automated fix deployment.
Details
🛡️ Threat Model & Affect Analysis — PR #35
| Field | Value |
|---|---|
| Repository | OpenVTC/verifiable-git-infrastructure |
| Branch | ci/resumable-publish → main |
| Generated | 2026-09-05 |
ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Code Review Report (which contains confirmed, materialised issues),
these are potential risks that may or may not be exploitable. Use this for defence-in-depth planning.
📋 Affect Analysis
Change Summary
Makes the crates.io publish workflow resumable/idempotent by checking the public sparse index before each crate publish and skipping crates already published at the current workspace version. This addresses a real prior incident where a non-atomic multi-crate publish left vgi-core published at a version mismatched with its dependents, forcing an unnecessary version bump to recover.
Diff: +45 / -1 lines
Types: ci_cd, reliability, supply_chain
Risk Assessment
- Overall Risk: medium
- Review Priority: before_merge
- Pentest Needed: false
- Security Review Needed: true
This change modifies the decision logic that gates a supply-chain-sensitive action — publishing packages to a public registry consumed by third parties. While it introduces no new credentials, does not weaken authentication, and only adds one new outbound read-only call to an already-implicitly-trusted public endpoint, the new skip logic is trusted at face value with no secondary verification, and its failure-handling collapses distinct failure modes (genuine absence vs. transient error) into a single code path. Combined with the workflow's own documented history of a real non-atomic multi-crate publish incident, an incorrect skip determination — whether from a spoofed/degraded network response or from ambiguous error handling — can reproduce or worsen that exact incident, now on a repeatable, potentially attacker-influenceable basis. The change is low-complexity and well-documented, but the risk is not purely theoretical given the documented precedent, warranting a focused pre-merge review of the new helper functions before this becomes the standard release path.
Review Focus Areas:
- Confirm error-handling in already_published() distinguishes genuine 404 from other failure modes before merge
- Confirm whether the unpinned git dependency issue described in the header comment has been separately fixed elsewhere in the codebase (Cargo.toml files not included in this diff)
- Confirm presence of an explicit
permissions:block and SHA-pinned third-party actions elsewhere in the full workflow file (not shown in this diff/excerpt) - Confirm curl timeout flags and job-level timeout-minutes are reasonable given no explicit bounds are added in this change
Pentest Focus:
- If a pentest of the broader release pipeline is ever scoped, verify whether an attacker with network-path influence over the GitHub-hosted runner's egress (unlikely in practice on GitHub-hosted runners, more relevant for self-hosted runners) could influence the index.crates.io response seen by already_published().
- Verify the OIDC Trusted Publishing configuration (repo + workflow filename binding) cannot be spoofed or hijacked by an unauthorized repository/workflow combination.
⚠️ Security Implications
🟠 Unauthenticated crates.io index response controls a security-relevant publish/skip decision
Unauthenticated crates.io index response controls a security-relevant publish/skip decision
Action: Add a post-publish cross-crate consistency verification step, and/or cross-check the skip decision against the authenticated crates.io API before finalizing a skip. Consider failing loudly (rather than silently skipping) when the index check itself is ambiguous.
🟡 curl failure modes conflated into a single 'not published' outcome
curl failure modes conflated into a single 'not published' outcome
Action: Capture the HTTP status code explicitly (e.g., curl -w '%{http_code}') and branch: treat 404 as 'not published', treat other non-2xx/network failures as a hard workflow error rather than a silent fallthrough to publish.
🟠 Root cause of prior version-mismatch incident (unpinned git dependency) remains unaddressed
Root cause of prior version-mismatch incident (unpinned git dependency) remains unaddressed
Action: Add a pre-flight step that fails the workflow if any workspace crate depends on vgi-core/verify-trust/did-git-sign via a git source with no version requirement, requiring exact-version or workspace-inherited path dependencies instead.
⚪ Idempotent, fail-closed workspace membership validation added
Idempotent, fail-closed workspace membership validation added
Action: No action needed; this is a good practice worth preserving in future refactors.
🔵 Reduced log grouping for newly added version-resolution and skip-decision logic
Reduced log grouping for newly added version-resolution and skip-decision logic
Action: Wrap the entire per-crate iteration (from cargo metadata through the end of the publish/skip decision) in a single ::group:: block.
⚪ index_path() lacks input validation, currently mitigated by hardcoded crate list
index_path() lacks input validation, currently mitigated by hardcoded crate list
Action: If the crate list is ever made dynamic, validate names against cargo's crate-name character rules before use in index_path().
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| Publish Workflow Job | medium | modified | The per-crate publish loop now resolves the workspace version via cargo metadata, queries the public crates.io sparse index via new `index |
📁 File Classifications
.github/workflows/publish.yml
- Type: security
🛡️ STRIDE Threat Model
Identified Threats (10)
🟠 STRIDE-1: Unauthenticated Index Response Spoofing in already_published()
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering, Denial of Service |
| Severity | High |
| Likelihood | Possible |
| CVSS | 7.6 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-345,CWE-300 |
| CAPEC | CAPEC-94,CAPEC-142 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: already_published() curl call to https://index.crates.io/<index_path> in .github/workflows/publish.yml allows response spoofing via network path tampering (MITM, DNS hijack, or crates.io CDN compromise) due to absence of TLS certificate pinning, response signature verification, or integrity checks, resulting in a false 'already published' determination that silently skips a legitimate cargo publish call.
Evidence: .github/workflows/publish.yml:~30-38
body=$(curl -sSf "https://index.crates.io/$(index_path "$name")" 2>/dev/null) || return 1
printf '%s\n' "$body" | jq -se --arg v "$version" 'any(.[]; .vers == $v)' >/dev/null
Attack Scenario:
- Attacker gains a network man-in-the-middle position on the GitHub Actions runner's egress path (compromised DNS resolver, malicious egress proxy, or compromised CDN edge node serving index.crates.io).
- The workflow executes
body=$(curl -sSf "https://index.crates.io/$(index_path "$name")" 2>/dev/null)for cratevgi-core,verify-trust, ordid-git-sign. - Attacker returns a forged sparse-index NDJSON body containing an entry with
.versequal to the current workspace version being published. already_published "$crate" "$version"evaluatesjq -se --arg v "$version" 'any(.[]; .vers == $v)'to true against the forged data.- The loop hits
continue, skippingcargo publish -p "${crate}" --lockedfor that crate entirely, even though it was never actually published. - Because publishing across the three crates is explicitly non-atomic (per the workflow's own comment), this reproduces or worsens the exact incident described in the comment (v0.4.7 vgi-core published against mismatched 0.4.6 dependents) — except now attacker-triggered rather than accidental, silently leaving crates.io in an inconsistent, unreviewed state with no workflow failure signal.
🔎 Threat Clue: Derived from COMP-001, COMP-003 via EP-002
- Data Flows: Runner -> index.crates.io HTTPS GET -> jq parse -> publish decision
Preconditions: Attacker must control or intercept network traffic between the GitHub-hosted runner and index.crates.io (MITM, DNS spoof, or supply-chain compromise of the index CDN), No certificate pinning or response authentication exists in the curl call
Existing Controls: curl -sSf enforces TLS (HTTPS) and fails on HTTP error status codes • set -euo pipefail causes script to abort on unhandled command failures
Recommended Mitigations: Cross-verify already-published status via the authenticated crates.io API (which requires valid session/token) in addition to the public sparse index • Add a secondary confirmation step post-skip that reconciles the final published state across all three crates before job success • Pin/verify TLS certificate fingerprint for index.crates.io or use crates.io's authenticated API with response signing where available • Emit a workflow-level warning/summary artifact enumerating skip decisions for human review before tag is considered fully released
🟡 STRIDE-2: Silent Skip via curl Failure Masking in already_published()
| Field | Detail |
|---|---|
| Category | Denial of Service, Repudiation |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-755,CWE-393 |
| CAPEC | CAPEC-227 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: already_published() function in .github/workflows/publish.yml allows denial-of-service-style publish suppression due to conflation of 'crate never published' (404) and generic curl/network failures into a single return code, resulting in legitimate publish attempts being wrongly treated as 404-equivalent and the crate potentially left unpublished without a hard failure.
Evidence: .github/workflows/publish.yml:~30-33
already_published() {
local name=$1 version=$2 body
body=$(curl -sSf "https://index.crates.io/$(index_path "$name")" 2>/dev/null) || return 1
...
Attack Scenario:
- During the tag-triggered publish run, transient network conditions (rate limiting, egress throttling, CDN hiccup, or an attacker-induced resource exhaustion against index.crates.io) cause
curl -sSfto fail for reasons other than a genuine 404. already_published()returns 1 (failure) via|| return 1regardless of the actual failure cause — a real transient 5xx, timeout, or attacker-triggered rate limiting is indistinguishable from a legitimate 404 'not yet published' response.- The caller
if already_published "$crate" "$version"; then ... continue; fitreats the function's non-zero return as 'not yet published', so it falls through tocargo publish -p "${crate}" --locked. - If cargo publish then also transiently fails (e.g., due to the same network condition or registry-side propagation delay), the crate is left unpublished with no distinct signal separating 'genuinely new' from 'index check failed', complicating incident diagnosis and enabling a repeat of the resumability bug the PR was designed to fix.
- Because there is no retry/backoff or explicit error classification, an attacker who can degrade index.crates.io availability during the exact publish window can force this ambiguous failure mode on demand, disrupting the release process without needing registry write access.
🔎 Threat Clue: Derived from COMP-003 via EP-002
- Data Flows: Runner -> index.crates.io HTTPS GET -> failure handling -> publish decision
Preconditions: Attacker or transient condition must be able to disrupt network requests to index.crates.io during the publish job window
Existing Controls: set -euo pipefail limits some silent failures elsewhere in the script • the 404 case is explicitly documented as 'a clean no' by the author
Recommended Mitigations: Distinguish HTTP 404 (crate/version genuinely absent) from other curl exit codes/HTTP statuses using curl -w '%{http_code}' and explicit branching • Add retry with exponential backoff around the index GET before concluding 'not published' • Emit a distinct log/error annotation when the index check itself fails versus when the crate is confirmed absent
🟡 STRIDE-3: TOCTOU Window Between Index Check and cargo publish
| Field | Detail |
|---|---|
| Category | Tampering, Denial of Service |
| Severity | Medium |
| Likelihood | Unlikely |
| CVSS | 4.8 CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-367 |
| CAPEC | CAPEC-26 |
| OWASP | A04:2021 - Insecure Design |
Description: The publish loop in .github/workflows/publish.yml allows a time-of-check-to-time-of-use inconsistency due to a non-atomic gap between the already_published() index query and the subsequent cargo publish -p "${crate}" --locked call, resulting in a possible duplicate-publish-attempt failure or race against concurrent workflow runs.
Evidence: .github/workflows/publish.yml:~40-48
if already_published "$crate" "$version"; then
echo "::notice::${crate} ${version} is already on crates.io — skipping"
continue
fi
cargo publish -p "${crate}" --locked
Attack Scenario:
- Two publish workflow runs are triggered close together (e.g., a tag pushed twice, or a re-run triggered manually while the original run is still in progress) — the workflow has no concurrency group to prevent overlapping runs for the same or adjacent tags.
- Both runs independently execute
already_published "$crate" "$version"for the same crate/version at nearly the same time, and both observe the crate as 'not yet published' (index not yet updated). - Both runs proceed to
cargo publish -p "${crate}" --lockedconcurrently. - One publish succeeds; the second fails with an 'already uploaded'-style error from crates.io, causing that job to fail non-gracefully (since the script has no handling for this specific race outcome) despite the underlying intent (crate correctly published) having been satisfied.
- This produces confusing CI failures and could mask a genuine problem in a later crate in the loop, since the script does not distinguish 'lost the race harmlessly' from 'a real publish error'.
🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-003
- Data Flows: Tag push -> concurrent workflow runs -> index check -> cargo publish race
Preconditions: Ability to trigger overlapping/concurrent workflow runs (duplicate tag pushes, manual re-run, or malicious repeated tag creation if tag creation is not access-controlled), No concurrency: group configured for the workflow
Existing Controls: cargo publish inherently rejects duplicate version uploads at the registry level, preventing actual data corruption
Recommended Mitigations: Add a concurrency: group keyed on the tag/ref to serialize publish workflow runs • Restrict tag-push permissions to trusted maintainers/protected tag rules • Treat crates.io 'already uploaded' errors during cargo publish as a non-fatal, logged condition rather than a hard failure
🟠 STRIDE-4: Unpinned Git Dependency Version Drift Across Non-Atomic Multi-Crate Publish
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | High |
| Likelihood | Likely |
| CVSS | 6.9 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-1357,CWE-829 |
| CAPEC | CAPEC-538 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: The sequential publish loop for vgi-core, verify-trust, and did-git-sign in .github/workflows/publish.yml allows publication of internally inconsistent crate versions due to lack of pre-publish validation that internal path/git dependencies resolve to matching pinned versions before any crate is uploaded, resulting in a repeat of the documented incident where vgi-core 0.4.7 was published against mismatched verify-trust/did-git-sign 0.4.6.
Evidence: .github/workflows/publish.yml:1-13
# Publishing is not atomic across three crates — an earlier one uploads, a later one can fail (a `git` dependency with no version requirement will do it, which is how v0.4.7 left `vgi-core` 0.4.7 published against `verify-trust`/`did-git-sign` 0.4.6).
Attack Scenario:
- A contributor (malicious or careless) modifies a workspace crate's
Cargo.tomlto depend onvgi-core,verify-trust, ordid-git-signvia agitdependency with no version requirement, as referenced in the workflow's own comment describing the root cause of the v0.4.7 incident. - A new tag
vX.Y.Zis pushed, triggering the workflow aton: push: tags: ["v*.*.*"](EP-001) with no branch protection or review gate specifically validating internal dependency pinning before publish. - The loop publishes
vgi-corefirst viacargo publish -p vgi-core --locked, which succeeds and lands on crates.io. - Publishing
verify-trustordid-git-signsubsequently fails for the unrelated reason described in the comment (the unpinned git dependency issue), leaving crates.io in a state wherevgi-coreis public at a new version but its sibling crates are not, i.e., an inconsistent published dependency graph, exactly mirroring the historical incident described in the leading comment block. - Downstream consumers who
cargo add vgi-coreduring this window pull a version whose intended companion crates are unavailable or mismatched, potentially breaking builds or, in adversarial scenarios, creating a window where an attacker could publish a competing/typosquatted crate to fill the perceived gap. - The already_published() skip logic added in this PR mitigates re-publish failures on retry but does not validate cross-crate version consistency before the first publish call runs, so the root architectural gap (no pre-flight consistency check across the three crates) remains unaddressed.
🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-003
- Data Flows: Tag push -> sequential per-crate publish -> partial success/failure across dependency graph
Preconditions: A workspace crate's Cargo.toml uses an unpinned git dependency on one of the three published crates, Publish workflow lacks a pre-flight step validating all three crate versions/dependency requirements are mutually consistent before any cargo publish call executes
Existing Controls: --locked flag ensures Cargo.lock is respected during publish, preventing dependency substitution at publish time • Sequential publish order (vgi-core first) is intentional so downstream crates can resolve it • already_published() prevents duplicate-publish retries from erroring out, aiding recovery after the fact
Recommended Mitigations: Add a pre-flight validation step that fails the workflow if any workspace crate depends on vgi-core/verify-trust/did-git-sign via git with no pinned version requirement • Require exact version (=x.y.z) or path dependencies resolved via workspace version inheritance for all internal crate references • Add a post-publish verification step confirming all three crates are present at mutually consistent versions on crates.io before marking the release complete • Consider publishing all crates from a single cargo publish --workspace-style atomic mechanism when tooling supports it, or add rollback/alerting on partial failure
🟡 STRIDE-5: Missing Least-Privilege Permissions Declaration Enabling Token Scope Creep
| Field | Detail |
|---|---|
| Category | Elevation of Privilege, Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.9 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-269,CWE-732 |
| CAPEC | CAPEC-122 |
| OWASP | A01:2021 - Broken Access Control |
Description: The publish.yml workflow allows excess GITHUB_TOKEN privilege exposure due to the absence of an explicit top-level or job-level permissions: block constraining the default token scope, resulting in a broader-than-necessary blast radius if any step in the job (including third-party actions invoked for OIDC auth) is compromised via a supply-chain attack.
Evidence: .github/workflows/publish.yml:N/A (not present in provided excerpt)
on:
push:
tags: ["v*.*.*"]
# (no permissions: block visible in provided excerpt)
Attack Scenario:
- The provided workflow excerpt shows no
permissions:key at workflow or job level, meaning the job runs with the repository's default GITHUB_TOKEN permission set, which for many repos defaults to broad read/write access. - An attacker compromises a transitive dependency of a third-party GitHub Action used in the
steps.authOIDC token-exchange step (supply-chain compromise), or a step in therun:block is manipulated via injected input. - Because no least-privilege
permissions:block restricts the job, the compromised step's GITHUB_TOKEN carries whatever elevated repo permissions are available by default (e.g., contents: write, packages: write), rather than being scoped to only what publish.yml needs (likelycontents: readandid-token: writefor OIDC). - The attacker uses the over-privileged token to push malicious commits, modify releases, or tamper with other repository state beyond the publish workflow's actual needs, executed entirely within the trusted CI context and therefore appearing as legitimate automated activity in audit logs.
- Combined with the OIDC-based crates.io Trusted Publishing token (
steps.auth.outputs.token), an attacker with code-execution in this job during a tag-triggered run could also directly attempt to leverage the ephemeral publish token for unauthorized crate uploads within its validity window.
🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-003
- Data Flows: Tag push -> job execution with default GITHUB_TOKEN scope -> OIDC auth -> cargo publish
Preconditions: Workflow lacks explicit permissions: minimization (not confirmable as absent in full file, but not shown in provided excerpt) and default repo settings grant elevated token permissions, An attacker achieves code execution within a job step (e.g., via compromised action or dependency)
Existing Controls: OIDC Trusted Publishing avoids long-lived crates.io credentials, limiting blast radius of that specific credential to a short-lived scoped token • Tag-based trigger (v*.*.*) limits execution to release events rather than every push/PR
Recommended Mitigations: Add explicit permissions: contents: read at workflow level and id-token: write only on the job requiring OIDC • Pin all third-party actions to a specific commit SHA rather than a tag/branch • Restrict tag creation/push to protected branches and required reviewers via repository rulesets
🔵 STRIDE-6: Unbounded Job Runtime Enabling Resource Exhaustion via Network Stall
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-400,CWE-1088 |
| CAPEC | CAPEC-227 |
| OWASP | A05:2021 - Security Misconfiguration |
Description: The publish job in .github/workflows/publish.yml allows CI runner resource exhaustion due to absence of an explicit timeout-minutes bounding the curl-based index check and cargo publish steps, resulting in prolonged runner occupation or stuck workflow runs if index.crates.io stalls or is targeted by a slow-loris style response.
Evidence: .github/workflows/publish.yml:~30-32
body=$(curl -sSf "https://index.crates.io/$(index_path "$name")" 2>/dev/null) || return 1
Attack Scenario:
- Attacker with network path influence (or exploiting crates.io CDN degradation) causes the
curl -sSf https://index.crates.io/...request in already_published() to hang indefinitely (slow response, keep-alive without completing body) rather than cleanly failing or timing out. - Because no explicit
--max-time/--connect-timeoutflags are passed to curl and notimeout-minutesis set at the job/step level (not shown as present in the provided excerpt), the shell script blocks indefinitely onbody=$(curl ...). - The GitHub Actions job consumes runner minutes/resources for an extended period (up to the platform's default 360-minute job limit) without making progress, delaying the release and consuming CI budget/quota.
- Repeated tag pushes during an active attack window could compound this into a broader CI resource-exhaustion condition, especially on self-hosted runners with limited capacity.
🔎 Threat Clue: Derived from COMP-003 via EP-002
- Data Flows: Runner -> index.crates.io HTTPS GET (potentially stalled)
Preconditions: Attacker able to degrade or stall network responses from index.crates.io to the specific runner, No explicit curl timeout flags or job-level timeout-minutes configured
Existing Controls: set -euo pipefail ensures other classes of errors abort the script promptly • GitHub Actions enforces a hard default job timeout (360 minutes) as a backstop
Recommended Mitigations: Add --max-time and --connect-timeout flags to the curl invocation • Set an explicit timeout-minutes on the publish job well below the platform default • Add retry-with-backoff logic bounded by a total elapsed-time budget
🔵 STRIDE-7: Insufficient Audit Trail for Skip Decisions Enabling Repudiation of Publish Outcomes
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 2.8 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N |
| Residual Severity | Low |
| CWE | CWE-778 |
| CAPEC | CAPEC-268 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: The publish loop in .github/workflows/publish.yml allows insufficient forensic traceability of release outcomes due to the skip/publish decision being logged only via ephemeral ::notice::/::error:: GitHub Actions annotations with no persisted, tamper-evident audit record, resulting in an inability to later verify or dispute which crates were actually published versus skipped for a given tag.
Evidence: .github/workflows/publish.yml:~43-48
echo "::notice::${crate} ${version} is already on crates.io — skipping"
continue
Attack Scenario:
- A release run publishes vgi-core but skips verify-trust and did-git-sign because already_published() reports a false positive (per STRIDE-1) or a legitimate prior partial publish.
- The only evidence of this decision is transient workflow log output (
::notice::${crate} ${version} is already on crates.io — skipping), which is subject to GitHub's log retention policy and is not independently attested, signed, or exported to an immutable audit store. - Weeks later, a security review or incident investigation needs to determine authoritatively whether a specific crate version was published by this automated pipeline or manually, but the ephemeral CI logs may have expired or been altered/deleted by anyone with workflow-log-delete permissions.
- Because there is no cryptographically signed release manifest or provenance attestation (e.g., SLSA provenance, Sigstore) tying the specific commit/tag to the exact set of crates published in that run, a malicious insider or compromised token holder could plausibly deny responsibility for an unauthorized or out-of-band publish action.
- This gap undermines the project's own security positioning (it produces
did-git-sign, a Git-signing/verification tool), creating an ironic inconsistency between the product's supply-chain integrity goals and its own release pipeline's auditability.
🔎 Threat Clue: Derived from COMP-001 via EP-003
- Data Flows: Publish decision -> ephemeral CI log annotation -> no persisted attestation
Preconditions: No external persisted audit log or provenance attestation is generated by the workflow, Reliance solely on default GitHub Actions log retention
Existing Controls: GitHub Actions run logs are retained for a default period and viewable by repo maintainers • ::group::/::notice::/::error:: annotations provide structured, human-readable run-time signal
Recommended Mitigations: Generate and publish SLSA provenance or Sigstore-signed attestations for each published crate artifact • Persist a structured release manifest (crate, version, published/skipped, timestamp, run ID) as a workflow artifact or to an external immutable log • Enable branch/tag protection and audit-log export to a SIEM for long-term retention
⚪ STRIDE-8: Crate Name/Path Injection into index_path() from Loop-Controlled but Future-Extensible Input
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure |
| Severity | Informational |
| Likelihood | Very Unlikely |
| CVSS | 1.0 CVSS:4.0/AV:N/AC:H/AT:P/PR:H/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | None |
| CWE | CWE-20,CWE-22 |
| CAPEC | CAPEC-126 |
| OWASP | A03:2021 - Injection |
Description: index_path() in .github/workflows/publish.yml allows path construction from a crate name variable due to lack of input validation/allow-listing of the $name parameter, resulting in a theoretical URL-path injection into the crates.io index request if the loop's hardcoded crate list is ever replaced with dynamically derived input (e.g., from Cargo.toml parsing of untrusted contributor changes).
Evidence: .github/workflows/publish.yml:~16-24
index_path() {
local name=$1
case ${#name} in
1) printf '1/%s\n' "$name" ;;
...
*) printf '%s/%s/%s\n' "${name:0:2}" "${name:2:2}" "$name" ;;
esac
}
Attack Scenario:
- Currently the loop
for crate in vgi-core verify-trust did-git-signhardcodes trusted crate names, so index_path() only ever receives these three literal strings — this threat is only realizable if the code evolves. - If a future maintainer refactors the loop to dynamically enumerate workspace members via
cargo metadataoutput (attacker-influenceable if a malicious PR adds a new workspace member with a crafted name, e.g., containing../or unexpected characters),index_path()builds a URL path via naive string slicing (${name:0:2},${name:2:2}) with no character-class validation. - A crafted crate name could theoretically manipulate the resulting curl URL path (e.g., path traversal segments), causing the workflow to query an unintended index path or, in a worst case, an attacker-controlled path structure if index.crates.io's URL parsing has quirks — though HTTPS and the fixed
index.crates.iohost constrain the actual impact to information disclosure of index contents at an unintended path, not arbitrary host redirection. - This remains a defense-in-depth/future-proofing concern rather than a currently exploitable vulnerability given the hardcoded crate list in this diff.
🔎 Threat Clue: Derived from COMP-003 via EP-002
- Data Flows: crate name variable -> index_path() -> curl URL
Preconditions: Future code change replaces the hardcoded crate list with dynamically derived, contributor-influenceable crate names, No validation of crate name character set is added at that time
Existing Controls: Current implementation uses a hardcoded, fixed list of three crate names (vgi-core verify-trust did-git-sign), eliminating attacker control today • cargo's own crate-name validation rules restrict publishable names to a limited character set at publish time
Recommended Mitigations: If the crate list is ever made dynamic, validate names against cargo's crate-name regex (`^[a-zA-Z0-9_-]+### 🛡️ AI Agentic Security Code Review
2 AI-confirmed issues, 2 findings need a human to review/validate.
Mandatory to check: 🔒 Security Code Review Report
🛡️ Security Code Review Report — PR #35
| Field | Value |
|---|---|
| Repository | OpenVTC/verifiable-git-infrastructure |
| Branch | ci/resumable-publish → main |
| Validated | 2026-09-05 |
| Scan ID | 055c4f7c |
| Validator | AI Security Validation Agent |
🗺️ Scan Coverage
Modules scanned: 1 · with findings: 1 · files: 1 · findings: 6
| Module | Files scanned | Findings |
|---|---|---|
.github |
1 | 6 |
Executive Summary
| Category | Confirmed | Must-Review-By-Human |
|---|---|---|
| Security Issues | 2 | 2 |
⚠️ 2 finding(s) need human review. These could not be conclusively confirmed or dismissed automatically (insufficient evidence). They are not dismissed — a developer / security team member must read and decide.
🔒 Security Issues
Confirmed Vulnerabilities (2)
🟡 Publish decision based on unauthenticated crates.io sparse-index query without integrity verification
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | .github/workflows/publish.yml |
| Finding ID | github_pr-8d480b4d1c6d |
| CWE | CWE-345 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
| Detection Source | threat_model |
🧠 AI Triage:
- Triaged severity: MEDIUM
- Code evidence confirms the vulnerable pattern (curl response used for a trust decision with no integrity verification) is present and reachable on every publish run, satisfying CWE-345. However, exploitation requires an attacker to control or manipulate the network path between the CI runner and index.crates.io (DNS poisoning, compromised network, or malicious proxy) — a non-trivial precondition with no known public exploit tooling (exploit_maturity: none) and no CVSS/EPSS data. Impact is scoped to release-pipeline correctness (skipped/duplicate publish), not direct compromise of production runtime systems or data exfiltration, so it does not meet the bar for high/critical. Medium is the well-anchored rating given confirmed reachability but limited exploit evidence and moderate, contained impact.
- Composite score: 4.9
- Environment: production
📝 Description:
The workflow decides whether to skip or perform cargo publish based solely on the response of an unauthenticated HTTPS GET to index.crates.io, with no signature or checksum verification of the returned index data.
🌱 Root Cause: The security-relevant control decision (skip vs. publish) relies entirely on trusting the content returned by curl from a third-party endpoint; a network-level MITM, DNS compromise, or compromised CDN edge could return a fabricated index entry.
🔎 Evidence: .github/workflows/publish.yml
already_published() {
local name=$1 version=$2 body
body=$(curl -sSf "https://index.crates.io/$(index_path "$name")" 2>/dev/null) || return 1
printf '%s\n' "$body" | jq -se --arg v "$version" 'any(.[]; .vers == $v)' >/dev/null
}
🎯 Attack Scenario:
An attacker capable of intercepting or spoofing responses from index.crates.io (e.g., DNS poisoning, compromised network path, or a malicious proxy in the CI runner environment) could return a fabricated 'already published' response for a legitimate crate/version, causing the workflow to silently skip publishing a fixed/patched release, or conversely fabricate a 'not published' response to trigger a duplicate/conflicting publish attempt.
Also flagged at this location (same code, other weakness framings): CWE755 — No timeout on curl call to crates.io index can cause indefinite workflow hang
🔍 Validation Log
- Verdict: ✅ Confirmed True Positive
- Confidence: 55%
- AI Validation Evidence: EVIDENCE FOUND: publish.yml already_published() does exactly
body=$(curl -sSf "https://index.crates.io/$(index_path "$name")" 2>/dev/null) || return 1followed byprintf '%s\n' "$body" | jq -se --arg v "$version" 'any(.[]; .vers == $v)' >/dev/nullwith no signature/checksum verification of the sparse-index response beyond TLS via curl -sSf. EVIDENCE NOT FOUND: no certificate pinning, no crates.io API cross-check, no response integrity check anywhere in the workflow file. CHANGED VS PRE-EXIST- Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.
⚠️ Must-Review-By-Human (2)
Validated up to a point, but inconclusive — a human must read the code and make the final call. Reported (not dismissed) so developers and the security team receive them.
🟡 Github Actions Mutable Action Tag (3 occurrences)
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | .github/workflows/publish.yml:38 |
| Finding ID | github_pr-0ddbfd51c18e |
| OWASP | A08:2021 - Software and Data Integrity Failures |
| CVSS 4.0 | 5.5 |
| Exploit Maturity | conceptual |
| Detection Source | mcp_semgrep |
Summary: GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-gi — 3 occurrence(s): publish.yml:38, publish.yml:40, publish.yml:46
📝 Description:
GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-gi
🌱 Root Cause: Github Actions Mutable Action Tag
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Priority: Short-term
Github Actions Mutable Action Tag: GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-gi
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 85%
- AI Validation Evidence: EVIDENCE FOUND: workflow uses
uses: actions/checkout@v7,uses: dtolnay/rust-toolchain@stable, anduses: rust-lang/crates-io-auth-action@v1— these are tag/branch references, not pinned to commit SHAs. EVIDENCE NOT FOUND: the finding claims '3 occurrences' at line 38 specifically but line 38 in the provided source corresponds touses: dtolnay/rust-toolchain@stable; exact occurrence-by-occurrence mapping to line numbers not fully verifiable from provided evidence. CHANGED VS PRE-EXISTING:- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
⚪ Cross-Crate Version Consistency Not Validated Pre-Publish (Non-Atomic Multi-Crate Release)
| Field | Detail |
|---|---|
| Severity | INFORMATIONAL |
| Location | .github/workflows/publish.yml:44 |
| Finding ID | github_pr-4f1cb779b984 |
| CWE | CWE-1357, CWE-829 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
| MITRE ATT&CK | T1195.001 - Compromise Software Dependencies and Development Tools |
| CAPEC | CAPEC-538 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | poc |
| Detection Source | skill_scan |
Summary: The publish workflow's per-crate loop lacks a pre-flight check ensuring vgi-core, verify-trust, and did-git-sign will resolve to mutually consistent, pinned versions before any of the three is uploaded to crates.io, allowing a repeat of a previously documented version-mismatch incident.
🧪 Proof of Concept:
There is no step prior to this loop that validates cross-crate dependency version consistency (e.g., checking Cargo.toml manifests for unpinned git dependencies among the three crates). The loop simply attempts to publish each crate in order and relies on cargo publish failing individually if there's a problem, by which point an earlier crate may already be live.
for crate in vgi-core verify-trust did-git-sign; do
version=$(cargo metadata --no-deps --format-version 1 \
| jq -r --arg n "$crate" '.packages[] | select(.name == $n) | .version')
if [ -z "$version" ]; then
echo "::error::${crate} is not a member of this workspace"
exit 1
fi
if already_published "$crate" "$version"; then
echo "::notice::${crate} ${version} is already on crates.io — skipping"
continue
fi
echo "::group::publish ${crate} ${version}"
cargo publish -p "${crate}" --locked
echo "::endgroup::"
done
Vulnerable lines: 44, 61
🔎 Evidence: .github/workflows/publish.yml:44
for crate in vgi-core verify-trust did-git-sign; do
version=$(cargo metadata --no-deps --format-version 1 | jq -r --arg n "$crate" '.packages[] | select(.name == $n) | .version')
...
cargo publish -p "${crate}" --locked
done
🧭 Reachability:
- Network exposure: public
- Auth barrier: none
- Attack path: EP-001 (tag push) → for-loop over crate list (line 44) → cargo metadata version lookup → cargo publish per crate (line 60) with no cross-crate dependency consistency check beforehand
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | high |
| Public exploit | None known |
| Environment | production |
Attack scenario: A careless or malicious contributor introduces an unpinned git dependency between the three workspace crates; when a new tag is pushed, the sequential non-atomic publish loop can succeed for an earlier crate and fail for a later one, leaving crates.io holding mismatched versions — exactly as previously occurred with vgi-core 0.4.7.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 90%
- AI Validation Evidence: EVIDENCE FOUND: the loop
for crate in vgi-core verify-trust did-git-sign; do ... cargo publish -p "${crate}" --locked; donehas no pre-flight step verifying cross-crate dependency version consistency before any publish call, as described. The workflow's own comment acknowledges this exact risk: 'Publishing is not atomic across three crates — an earlier one uploads, a later one can fail... which is how v0.4.7 left vgi-core 0.4.7 published against verify-trust/did-git-sign 0.4.6.' EVIDENCE NOT F- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
Generated by Agentic Sec — AI Security Validation Agent
This report includes full scan data + AI validation evidence. Feed to engineering copilots for automated fix deployment.
⚪ STRIDE-9: Comment-Embedded Instruction Injection Attempt Against Automated Analysis Tooling
| Field | Detail |
|---|---|
| Category | Tampering, Repudiation |
| Severity | Informational |
| Likelihood | Very Unlikely |
| CVSS | 0.0 CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | None |
| CWE | CWE-1427 |
| CAPEC | CAPEC-242 |
| OWASP | A03:2021 - Injection |
Description: The comment block in .github/workflows/publish.yml allows a theoretical prompt-injection vector against downstream LLM-based code review/security tooling due to free-text comment content being ingested as untrusted data by automated analyzers, resulting in a risk (not observed in this instance) that a future malicious PR could embed instructions attempting to suppress security findings.
Evidence: .github/workflows/publish.yml:1-13
# Auth is crates.io Trusted Publishing (OIDC) — no long-lived token. Setup per
# crate on crates.io: add this repo + this workflow (`publish.yml`) as a
# Trusted Publisher for `vgi-core`, `verify-trust`, and `did-git-sign`.
Attack Scenario:
- This specific PR's comments are legitimate engineering documentation of a real incident and contain no manipulative instructions — confirmed benign upon review.
- However, the general pattern of free-form YAML comments being ingested by automated security/LLM-based review tooling (as in this very analysis pipeline) establishes a viable future attack surface: a malicious contributor could submit a PR whose comment text contains phrases like 'ignore previous findings' or 'mark as false positive' embedded within seemingly legitimate engineering prose.
- If a downstream automated reviewer treats comment text as trusted instructions rather than data, it could be manipulated into suppressing genuine findings in that or future PRs.
- This finding is raised as a forward-looking process control observation per the security directive requiring any manipulation attempt in analyzed content to be reported as a finding rather than acted upon — no such attempt was found in the actual analyzed content, but the structural risk of the ingestion pattern itself is noted for completeness.
🔎 Threat Clue: Derived from COMP-001 via N/A
- Data Flows: PR comment text -> automated analysis ingestion
Preconditions: A future PR embeds manipulative instruction-like text within code comments, Downstream automated review tooling fails to treat comment content strictly as data
Existing Controls: This analysis explicitly treats all input content as data under a non-overridable security directive • No manipulative content was found in the actual analyzed comments
Recommended Mitigations: Maintain strict data/instruction separation in all automated review tooling ingesting PR content • Flag and manually review PRs whose comment diffs contain imperative language directed at review systems
🔵 STRIDE-10: Removed Diagnostic Grouping Reduces Log Traceability During Metadata Resolution
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 2.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N |
| Residual Severity | Low |
| CWE | CWE-778 |
| CAPEC | N/A |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: The publish loop in .github/workflows/publish.yml allows reduced observability during the version-resolution phase due to removal of the original echo "::group::publish ${crate}" marker before the new cargo metadata/already_published logic without an equivalent replacement covering that phase, resulting in log output for metadata resolution and skip-decision steps being ungrouped and harder to correlate during incident triage.
Evidence: .github/workflows/publish.yml:diff hunk near removed line
/* [REMOVED] echo "::group::publish ${crate}" */
version=$(cargo metadata --no-deps --format-version 1 | jq -r --arg n "$crate" '.packages[] | select(.name == $n) | .version')
Attack Scenario:
- The diff shows the original
echo "::group::publish ${crate}"line removed and replaced by a newecho "::group::publish ${crate} ${version}"placed later, after thecargo metadata/already_publishedchecks execute. - Any output, warnings, or errors produced during the
cargo metadata --no-depscall or thealready_publishedcheck now occur outside of any::group::block, appearing as ungrouped top-level log lines in the Actions UI. - During a live incident (e.g., diagnosing STRIDE-1 or STRIDE-2 above), an on-call engineer reviewing the collapsed/grouped log view may overlook these ungrouped lines since GitHub Actions visually collapses
::group::sections by default, making non-grouped diagnostic output easy to miss. - This does not affect the correctness of the publish decision itself but degrades the traceability and speed of manual log-based root-cause analysis, indirectly increasing the mean-time-to-detect for the other threats described here.
🔎 Threat Clue: Derived from COMP-001 via EP-003
- Data Flows: Per-crate loop iteration log output
Preconditions: An incident occurs during the metadata resolution or already_published check phase, generating log output that would benefit from grouping
Existing Controls: The final cargo publish step retains its own ::group::/::endgroup:: wrapper • ::notice:: and ::error:: annotations are still emitted and rendered distinctly by GitHub Actions regardless of grouping
Recommended Mitigations: Wrap the entire per-crate iteration (metadata lookup through publish) in a single ::group::${crate} block for consistent log grouping • Add explicit debug-level logging of the resolved version and skip/publish decision within that group
🍝 PASTA Threat Model
Application Purpose
A GitHub Actions release pipeline that publishes three interdependent Rust crates (vgi-core, verify-trust, did-git-sign) forming a verifiable Git infrastructure/signing toolchain to crates.io using OIDC-based Trusted Publishing, with new resumability logic to safely skip already-published crates on retry.
Inherent Risks
- Multi-crate publishing across a dependency graph is inherently non-atomic when using sequential
cargo publishcalls. - The public crates.io sparse index has no cryptographic response authentication beyond standard TLS, so integrity relies entirely on transport security and registry trustworthiness.
- CI/CD release pipelines are high-value targets because compromise yields direct supply-chain impact on all downstream consumers of the published crates.
Objectives
Risk: Tolerate transient network failures without silently producing an inconsistent published dependency graph
Business: Reliably ship verified releases of vgi-core, verify-trust, and did-git-sign to crates.io without manual intervention
Security: Ensure only intended, integrity-verified crate versions are published to crates.io under Trusted Publishing; Protect the OIDC-derived publish token and CI runner from misuse
Financial: Avoid costly incident response and reputational damage from a broken or compromised release pipeline
Compliance: Maintain a defensible, auditable record of release provenance consistent with supply-chain security expectations for a signing/verification product
Functional: Support resumable, idempotent publishing that tolerates partial prior failures without requiring unnecessary version bumps
Operational: Minimize manual toil in the release process while preserving auditability of what was published and when
Business Impact Analysis (1)
BIA-1: Crate Release Publishing Pipeline (High)
Automated tag-triggered workflow that publishes vgi-core, verify-trust, and did-git-sign to crates.io via OIDC Trusted Publishing, now with idempotent skip-if-already-published logic.
MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours
- Stakeholders: Downstream Crate Consumers / OpenVTC Maintainers / Security Reviewers
- Dependencies: Cargo/Rust Toolchain / GitHub Actions Runner / crates.io Registry API / crates.io Sparse Index (index.crates.io) / OIDC Trusted Publishing Auth Action
- Disruptions: Partial multi-crate publish failure leaving mismatched dependency versions live / Spoofed or degraded crates.io index responses causing incorrect skip decisions / Concurrent/duplicate workflow runs racing on the same tag
- Impacts: Broken downstream builds for consumers pulling an inconsistent vgi-core/verify-trust/did-git-sign version set / Delayed releases requiring unnecessary version bumps to recover / Erosion of trust in a security-focused product (did-git-sign) whose own supply chain shows integrity gaps
Technical Scope
Roles (2): RO-1 Repository Maintainer · RO-2 CI Automation
Actors (3): AC-1 Maintainer · AC-2 GitHub Actions Runner · AC-3 crates.io Service
Entry Points (3): EP-1 Tag Push Trigger · EP-2 Sparse Index Query · EP-3 Cargo Publish Call
Threat Actors (3): TA-1 Network-Position Attacker (MITM/DNS) · TA-2 Malicious/Compromised Contributor · TA-3 Supply-Chain Attacker (Third-Party Action)
Infrastructure (1): IF-1 GitHub-Hosted Actions Runner
Trust Boundaries (2): TB-1 GitHub Actions CI Runner Boundary · TB-2 Public Internet / crates.io Boundary
External Entities (2): EE-1 crates.io Registry · EE-2 Downstream Crate Consumers
System Components (3): SC-1 Publish Workflow Job · SC-2 crates.io Registry API · SC-3 crates.io Sparse Index (index.crates.io)
Resources And Assets (3): RA-1 OIDC-Derived Publish Token · RA-2 Published Crate Packages · RA-3 Crate Version Index Data
Technologies And Dependencies (4): TD-1 Cargo · TD-2 jq · TD-3 curl · TD-4 crates.io Trusted Publishing OIDC Action
Use Cases (2)
- Tag-Triggered Crate Release: A maintainer pushes a semver tag, which triggers the publish workflow to authenticate via OIDC, check crates.io for already-published versions, and publish any remaining crates in dependency order.
- Idempotent Publish Resumption: After a prior partial failure, the maintainer re-runs the workflow; the pipeline detects crates already present on crates.io at the workspace version and skips them, publishing only the remainder.
📋 Risk Registry (6)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-1 | False publish-skip decisions caused by spoofed or degraded crates.io index responses | High | Medium | Short-Term | Medium |
| RISK-2 | Non-atomic multi-crate publish leaves an inconsistent dependency graph live on crates.io | High | Medium | Short-Term | High |
| RISK-3 | Overly broad default CI token permissions increase blast radius of a compromised action or dependency | Medium | Low | Immediate | Low |
| RISK-4 | Concurrent or duplicate workflow runs racing on the same tag cause confusing partial failures | Medium | Low | Medium-Term | Low |
| RISK-5 | Insufficient persisted audit trail for publish/skip decisions undermines incident forensics | Low | Low | Medium-Term | Medium |
| RISK-6 | Unbounded network wait during index check risks CI resource exhaustion | Low | Low | Short-Term | Low |
⚔️ Attack Scenarios (2)
SC-1: Publish Workflow Job
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Network-Position Attacker<br><i>Manipulate publish decisions</i>" }
TA2@{ shape: rect, label: "TA-2: Malicious/Compromised Contributor<br><i>Corrupt dependency graph</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Unauthenticated Index Response Spoofing<br><i>High / Possible</i>" }
S4@{ shape: rect, label: "STRIDE-4: Unpinned Git Dependency Version Drift<br><i>High / Likely</i>" }
S3@{ shape: rect, label: "STRIDE-3: TOCTOU Window<br><i>Medium / Unlikely</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C94@{ shape: rect, label: "CAPEC-94: Adversary in the Middle" }
C538@{ shape: rect, label: "CAPEC-538: Open-Source Supply Chain" }
C26@{ shape: rect, label: "CAPEC-26: Leveraging Race Conditions" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
W1357@{ shape: rect, label: "CWE-1357: Reliance on Insufficiently Trustworthy Component" }
W367@{ shape: rect, label: "CWE-367: TOCTOU Race Condition" }
end
subgraph SL5["5. System Component"]
direction LR
SC1@{ shape: rect, label: "SC-1: Publish Workflow Job" }
end
TA1 --> S1
TA2 --> S4
TA1 --> S3
S1 --> C94
S4 --> C538
S3 --> C26
C94 --> W345
C538 --> W1357
C26 --> W367
W345 --> SC1
W1357 --> SC1
W367 --> SC1
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#FF0000,stroke-width:2px
linkStyle 2 stroke:#FFA500,stroke-width:2px
linkStyle 3 stroke:#FF0000,stroke-width:2px
linkStyle 4 stroke:#FF0000,stroke-width:2px
linkStyle 5 stroke:#FFA500,stroke-width:2px
linkStyle 6 stroke:#FF0000,stroke-width:2px
linkStyle 7 stroke:#FF0000,stroke-width:2px
linkStyle 8 stroke:#FFA500,stroke-width:2px
linkStyle 9 stroke:#FF0000,stroke-width:2px
linkStyle 10 stroke:#FF0000,stroke-width:2px
linkStyle 11 stroke:#FFA500,stroke-width:2px
SC-3: crates.io Sparse Index
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Network-Position Attacker<br><i>Manipulate publish decisions</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S2@{ shape: rect, label: "STRIDE-2: Silent Skip via curl Failure Masking<br><i>Medium / Likely</i>" }
S6@{ shape: rect, label: "STRIDE-6: Unbounded Job Runtime Resource Exhaustion<br><i>Low / Unlikely</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C227@{ shape: rect, label: "CAPEC-227: Sustained Client Engagement" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W755@{ shape: rect, label: "CWE-755: Improper Handling of Exceptional Conditions" }
W400@{ shape: rect, label: "CWE-400: Uncontrolled Resource Consumption" }
end
subgraph SL5["5. System Component"]
direction LR
SC3@{ shape: rect, label: "SC-3: crates.io Sparse Index" }
end
TA1 --> S2
TA1 --> S6
S2 --> C227
S6 --> C227
C227 --> W755
C227 --> W400
W755 --> SC3
W400 --> SC3
linkStyle 0 stroke:#FFA500,stroke-width:2px
linkStyle 1 stroke:#00FF00,stroke-width:2px
linkStyle 2 stroke:#FFA500,stroke-width:2px
linkStyle 3 stroke:#00FF00,stroke-width:2px
linkStyle 4 stroke:#FFA500,stroke-width:2px
linkStyle 5 stroke:#00FF00,stroke-width:2px
linkStyle 6 stroke:#FFA500,stroke-width:2px
linkStyle 7 stroke:#00FF00,stroke-width:2px
📊 Risk Summary
Total Threats: 10
By Severity: Low: 3 · High: 2 · Medium: 3 · Informational: 2
By Category: Spoofing: 1 · Tampering: 6 · Denial of Service: 4 · Repudiation: 4 · Elevation of Privilege: 1 · Information Disclosure: 1
🎯 Attack Surface
Kill Chain 1: A network-position attacker (TA-1) intercepts the unauthenticated HTTPS GET to index.crates.io performed by already_published() (STRIDE-1), forging a response that falsely reports a crate/version as already published; combined with the workflow's own documented non-atomicity across the three-crate publish sequence (STRIDE-4), this can be chained to silently suppress a legitimate cargo publish call for one crate while its dependents proceed, recreating and weaponizing the exact version-mismatch incident the workflow's comments describe as having already occurred once accidentally. Kill Chain 2: A malicious or careless contributor (TA-2) introduces an unpinned git dependency reference between the three internal crates (STRIDE-4); when a release tag is pushed, the sequential, non-atomic publish loop uploads vgi-core successfully before failing on a downstream crate, and because the pipeline lacks a pre-flight cross-crate consistency check, the inconsistency reaches crates.io and is only discoverable after the fact via manual retry, at which point the newly added already_published() skip logic (intended as a fix) could itself be spoofed per Kill Chain 1 to worsen recovery. Kill Chain 3: A supply-chain attacker (TA-3) compromises a third-party GitHub Action or transitive CLI dependency (curl/jq/OIDC auth action) used within the publish job; absent an explicit least-privilege permissions block (STRIDE-5), the compromised step inherits broader-than-necessary default GITHUB_TOKEN scope, and absent job timeouts or curl timeouts (STRIDE-6), the attacker also gains an extended dwell-time window within the ephemeral runner to attempt lateral actions before the job naturally terminates, all while ephemeral, ungrouped logging (STRIDE-7, STRIDE-10) reduces the likelihood that anomalous behavior is detected and attributed after the fact.
🛡️ Risk Mitigation Strategy
Priority 1 (Immediate): Add an explicit least-privilege permissions: block scoped to contents: read and id-token: write only, and pin all third-party actions (including the OIDC auth action) to specific commit SHAs — this closes the highest-leverage, lowest-effort gap (STRIDE-5/RISK-3) that would otherwise amplify the impact of any other compromise in the pipeline. Priority 2 (Short-Term): Harden the already_published() integrity model by distinguishing genuine 404s from transient/spoofed failures via explicit HTTP status checks, adding curl timeout flags, and cross-validating against the authenticated crates.io API where feasible — this directly addresses STRIDE-1/STRIDE-2/STRIDE-6/RISK-1/RISK-6, the most exploitable and highest-severity gaps introduced or highlighted by this diff. Priority 3 (Short-to-Medium-Term): Implement a pre-flight cross-crate dependency-pinning validation step and a post-publish consistency verification across vgi-core/verify-trust/did-git-sign to prevent recurrence of the documented version-mismatch incident (STRIDE-4/RISK-2), since this remains the most business-impactful residual gap despite the resumability fix in this PR. Priority 4 (Medium-Term): Improve forensic auditability by restoring consistent ::group:: log wrapping, persisting a structured release manifest artifact, and evaluating SLSA provenance/Sigstore attestation generation for published crates (STRIDE-7/STRIDE-10/RISK-5), reinforcing the project's own supply-chain integrity narrative given its purpose as a Git-signing/verification toolchain. Priority 5 (Medium-Term): Add a concurrency: group keyed on the release tag to eliminate the low-likelihood but real TOCTOU race between overlapping workflow runs (STRIDE-3/RISK-4), completing the defense-in-depth posture for the release pipeline.
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 2 | 2 |
Confirmed (2)
- 🟡 No timeout on curl call to crates.io index can cause indefinite workflow hang
- 🟡 Publish decision based on unauthenticated crates.io sparse-index query without integrity verification
Must-Review-By-Human (2)
- 🟡 Github Actions Mutable Action Tag (3 occurrences)
- ⚪ Cross-Crate Version Consistency Not Validated Pre-Publish (Non-Atomic Multi-Crate Release)
Publishing three crates is not atomic. An earlier one uploads, a later one
fails, and the tag has half-published — which is exactly what v0.4.7 did:
cargo publishrejectedverify-trustbecause it takestrql-clientbygitwith no version requirement, aftervgi-core0.4.7 had already goneup. crates.io now holds vgi-core 0.4.7 against verify-trust and did-git-sign
0.4.6.
Before this change there was no way back. Re-running the tag dies on "crate
version is already uploaded" for the crate that did land, so finishing the
release meant bumping every crate to a version none of them needed, purely to
get past a name that was already correct.
Each crate is now checked against the sparse index for its workspace version
first and skipped if it is there, so a re-run finishes whatever the failed run
started. The check reads index.crates.io directly rather than parsing cargo's
error text, which is not a stable interface. A 404 — a name that has never
been published — is a clean "not published" and falls through to publishing,
so a brand-new crate is unaffected.
Verified against the live index: vgi-core 0.4.7 reports as already published
while verify-trust and did-git-sign 0.4.7 do not, which is precisely the
resume this is for.
Signed-off-by: Glenn Gore glenn.g@affinidi.com