Skip to content

ci(publish): skip crates already on crates.io so a run is resumable - #35

Merged
stormer78 merged 1 commit into
mainfrom
ci/resumable-publish
Aug 30, 2026
Merged

ci(publish): skip crates already on crates.io so a run is resumable#35
stormer78 merged 1 commit into
mainfrom
ci/resumable-publish

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

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

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>
@stormer78
stormer78 merged commit 148a6e0 into main Aug 30, 2026
6 checks passed
@stormer78
stormer78 deleted the ci/resumable-publish branch August 30, 2026 10:08
@affinidi-appsecurity-bot

affinidi-appsecurity-bot commented Aug 30, 2026

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

2 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

Field Value
Repository OpenVTC/verifiable-git-infrastructure
Branch ci/resumable-publishmain
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 1 followed by printf '%s\n' "$body" | jq -se --arg v "$version" 'any(.[]; .vers == $v)' >/dev/null with 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, and uses: 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 to uses: 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; done has 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.

Complementary: 🛡️ **Threat Model & Affect Analysis**
Details

🛡️ Threat Model & Affect Analysis — PR #35

Field Value
Repository OpenVTC/verifiable-git-infrastructure
Branch ci/resumable-publishmain
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:

  1. 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).
  2. The workflow executes body=$(curl -sSf "https://index.crates.io/$(index_path "$name")" 2>/dev/null) for crate vgi-core, verify-trust, or did-git-sign.
  3. Attacker returns a forged sparse-index NDJSON body containing an entry with .vers equal to the current workspace version being published.
  4. already_published "$crate" "$version" evaluates jq -se --arg v "$version" 'any(.[]; .vers == $v)' to true against the forged data.
  5. The loop hits continue, skipping cargo publish -p "${crate}" --locked for that crate entirely, even though it was never actually published.
  6. 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:

  1. 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 -sSf to fail for reasons other than a genuine 404.
  2. already_published() returns 1 (failure) via || return 1 regardless 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.
  3. The caller if already_published "$crate" "$version"; then ... continue; fi treats the function's non-zero return as 'not yet published', so it falls through to cargo publish -p "${crate}" --locked.
  4. 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.
  5. 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:

  1. 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.
  2. 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).
  3. Both runs proceed to cargo publish -p "${crate}" --locked concurrently.
  4. 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.
  5. 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:

  1. A contributor (malicious or careless) modifies a workspace crate's Cargo.toml to depend on vgi-core, verify-trust, or did-git-sign via a git dependency with no version requirement, as referenced in the workflow's own comment describing the root cause of the v0.4.7 incident.
  2. A new tag vX.Y.Z is pushed, triggering the workflow at on: push: tags: ["v*.*.*"] (EP-001) with no branch protection or review gate specifically validating internal dependency pinning before publish.
  3. The loop publishes vgi-core first via cargo publish -p vgi-core --locked, which succeeds and lands on crates.io.
  4. Publishing verify-trust or did-git-sign subsequently fails for the unrelated reason described in the comment (the unpinned git dependency issue), leaving crates.io in a state where vgi-core is 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.
  5. Downstream consumers who cargo add vgi-core during 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.
  6. 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:

  1. 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.
  2. An attacker compromises a transitive dependency of a third-party GitHub Action used in the steps.auth OIDC token-exchange step (supply-chain compromise), or a step in the run: block is manipulated via injected input.
  3. 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 (likely contents: read and id-token: write for OIDC).
  4. 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.
  5. 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:

  1. 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.
  2. Because no explicit --max-time/--connect-timeout flags are passed to curl and no timeout-minutes is set at the job/step level (not shown as present in the provided excerpt), the shell script blocks indefinitely on body=$(curl ...).
  3. 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.
  4. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

  1. Currently the loop for crate in vgi-core verify-trust did-git-sign hardcodes trusted crate names, so index_path() only ever receives these three literal strings — this threat is only realizable if the code evolves.
  2. If a future maintainer refactors the loop to dynamically enumerate workspace members via cargo metadata output (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.
  3. 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.io host constrain the actual impact to information disclosure of index contents at an unintended path, not arbitrary host redirection.
  4. 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-publishmain
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 1 followed by printf '%s\n' "$body" | jq -se --arg v "$version" 'any(.[]; .vers == $v)' >/dev/null with 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, and uses: 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 to uses: 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; done has 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.

Complementary: 🛡️ **Threat Model & Affect Analysis** ) before use in index_path() • Use `curl --data-urlencode` style encoding or a URL-building library rather than raw string interpolation for any future dynamic input

⚪ 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:

  1. This specific PR's comments are legitimate engineering documentation of a real incident and contain no manipulative instructions — confirmed benign upon review.
  2. 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.
  3. 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.
  4. 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:

  1. The diff shows the original echo "::group::publish ${crate}" line removed and replaced by a new echo "::group::publish ${crate} ${version}" placed later, after the cargo metadata/already_published checks execute.
  2. Any output, warnings, or errors produced during the cargo metadata --no-deps call or the already_published check now occur outside of any ::group:: block, appearing as ungrouped top-level log lines in the Actions UI.
  3. 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.
  4. 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 publish calls.
  • 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
Loading

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
Loading

📊 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 ⚠️ Must-Review-By-Human
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)

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.

2 participants