Skip to content

ci(semver): block a release whose version bump is smaller than its API change - #1256

Merged
stormer78 merged 1 commit into
mainfrom
ci/release-bump-guard
Sep 5, 2026
Merged

ci(semver): block a release whose version bump is smaller than its API change#1256
stormer78 merged 1 commit into
mainfrom
ci/release-bump-guard

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

release-plz runs cargo-semver-checks itself and is meant to raise the bump when a
crate actually broke. On 2026-09-05 it did not, and nothing caught it:

vti-common: next version is 0.16.2 (✓ API compatible changes)
vta-sdk:    next version is 0.32.4 (✓ API compatible changes)

Run directly against the same baseline, the same tool disagrees:

Crate Lint Detail
vti-common enum_variant_added Capability gained MemoryRead, MemoryWrite, RoomPresent, RoomOpen; AuditEvent gained RoomOperation. Neither is #[non_exhaustive], so a downstream exhaustive match stops compiling.
vta-sdk constructible_struct_adds_field ×16 CreateAclBody, AclEntry, IssueCredentialBody, MemoryPut/List/DeleteBody, UpdateConfigBody and others gained a public ext field, breaking struct-literal construction — the expected usage for an SDK's wire types.

Both were about to ship as patch releases, which ^0.16 and ^0.32
consumers take automatically: a compile break delivered by a routine update.
RELEASING.md's rule is the opposite — the release moves the compatibility field
instead of shipping a break as a patch.

Why the existing report cannot catch this

It runs on ordinary PRs, where the manifest still carries the published
version. cargo-semver-checks compares 0.16.1 against 0.16.1 and can only answer
"a break exists" — never "the chosen bump is too small", because no bump has
been chosen yet.

A release-plz branch is the one place the question is answerable: every manifest
already holds the version about to be published, so cargo-semver-checks derives
the release type from the real delta and a failure means precisely that the delta
is insufficient.

So this is not new logic or a reimplementation of semver arithmetic. It is the
same check, the same script, the same exclusion list and coverage assertion — run
at the one point where it can answer the right question, and enforced instead of
reported. SEMVER_MODE=enforce selects it; the default stays report.

Verified both directions, against the real release branch

0.16.2 / 0.32.4  ->  "(minor change)"  ->  fails, exit 100
0.17.0 / 0.33.0  ->  "(major change)"  ->  passes, exit 0

The second run bumped both crates to their breaking slot and moved all 26
internal requirements, as release-plz would. It blocks an under-bump and permits
a properly declared breaking release — rather than being red on every release
and therefore ignored, which is the failure mode this job family has already had
twice (#1252, #1253).

Scope

  • Runs only when github.head_ref starts with release-plz-; ordinary PRs are
    unaffected and still get the informational report.
  • No continue-on-error. That is the entire point.
  • To make it binding, add "release bump is large enough" to the branch
    protection required checks — it only ever runs on release branches, so it
    cannot block anything else.

Not fixed here

Release PR #1232 still proposes the under-bumped versions. The correct values
are vti-common 0.17.0 and vta-sdk 0.33.0. Why release-plz reports these as
compatible is still unexplained: I ruled out the unpublished-crate abort (a
rerun with both crates published gave the same answer), baseline mismatch (the
git tag and the crates.io artifact agree, and neither has the variants), feature
gating, and workspace-vs-package mode. The only invocation that yields
release-plz's verdict is --release-type major, under which the tool runs zero
checks and skips all 254 — i.e. "breaking is permitted", not "nothing broke".
release-plz does not log its invocation, so that remains a hypothesis. Worth
raising upstream separately; this guard makes the disagreement harmless either
way.

…I change

release-plz runs cargo-semver-checks itself and is meant to raise the bump when
a crate actually broke. On 2026-09-05 it did not, and nothing caught it:

  vti-common: next version is 0.16.2 (✓ API compatible changes)
  vta-sdk:    next version is 0.32.4 (✓ API compatible changes)

against a baseline where the same tool, run directly, fails `enum_variant_added`
on two exhaustive enums in `vti-common` (`Capability` gained four variants,
`AuditEvent` one) and `constructible_struct_adds_field` on sixteen public
`vta-sdk` wire structs that gained an `ext` field. Both were about to ship as
patch releases, which `^0.16` and `^0.32` consumers take automatically — a
compile break delivered by a routine update. The workspace has an explicit rule
against exactly this: the release moves the compatibility field instead of
shipping a break as a patch.

The report job cannot catch it. It runs on ordinary PRs, where the manifest
still holds the PUBLISHED version, so cargo-semver-checks compares 0.16.1
against 0.16.1 and can only answer "a break exists" — never "the chosen bump is
too small", because no bump has been chosen yet.

A release-plz branch is different: every manifest already carries the version
about to be published. cargo-semver-checks then derives the release type from
the real delta, and a failure means precisely that the delta is too small. So
the guard is not new logic or a reimplementation of semver arithmetic — it is
the same check, run at the one point where the question is answerable, and
enforced rather than reported.

Verified both directions against the actual release branch:

  0.16.2 / 0.32.4  -> "(minor change)" -> fails, exit 100
  0.17.0 / 0.33.0  -> "(major change)" -> passes, exit 0

so it blocks an under-bump and permits a properly declared breaking release,
rather than being red on every release and therefore ignored — which is the
failure mode this job family has already had twice.

`SEMVER_MODE=enforce` selects it; the default stays `report`. One script, one
exclusion list, one coverage assertion, two severities.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 requested a review from a team as a code owner September 5, 2026 20:53
@stormer78
stormer78 merged commit e393e38 into main Sep 5, 2026
13 of 14 checks passed
@stormer78
stormer78 deleted the ci/release-bump-guard branch September 5, 2026 21:13
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

4 AI-confirmed issues.

Mandatory to check: 🔒 Security Code Review Report

Details

🛡️ Security Code Review Report — PR #1256

Field Value
Repository OpenVTC/verifiable-trust-infrastructure
Branch ci/release-bump-guardmain
Validated 2026-09-05
Scan ID 98b8db65
Validator AI Security Validation Agent

🗺️ Scan Coverage

Modules scanned: 2 · with findings: 2 · files: 2 · findings: 7

Module Files scanned Findings
.github 1 5
scripts 1 2

Executive Summary

Category Confirmed Must-Review-By-Human
Security Issues 4 0

🔒 Security Issues

Confirmed Vulnerabilities (4)

🟡 Branch-name string match used as sole trust boundary for release-bump-guard enforcement

Field Detail
Severity MEDIUM
Location .github/workflows/ci.yml:516
Finding ID github_pr-8192bb4aede5
CWE CWE-807, CWE-290
OWASP A07:2021 - Identification and Authentication Failures
MITRE ATT&CK T1195 - Supply Chain Compromise, T1548 - Abuse Elevation Control Mechanism
CAPEC CAPEC-151, CAPEC-698
DREAD 7.8
Reachability 🔴 Reachable
Exploit Maturity poc
Detection Source skill_scan

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — Scanner code evidence directly confirms the vulnerable if-condition at ci.yml:516-543 relies solely on `startsWith(github.head_ref, 'release-plz-')` with no actor check — this is proven, reachable, and trivially exploitable (any forked PR branch name). Exploit maturity is effectively 'trivial to reproduce' (no special tooling), auth_barrier=none, network_exposure=public. However this is capped from critical because impact is confined to CI/release-process integrity disruption, not RCE, secrets exposure, or direct data breach — hence high, not critical.
  • Composite score: 6.1
  • Environment: production

Summary: Any user who can open a pull request (including from a public fork) can create a branch literally named release-plz-x and thereby control whether the hard-blocking release-bump-guard job runs on their PR, without any verification that release-plz actually produced the branch. This lets an attacker either evade the intended enforcement or weaponize it as a nuisance/denial-of-service against unrelated PRs.

📝 Description:

An attacker can force or avoid the repository's only hard-blocking API-compatibility gate, potentially allowing a breaking change to ship as a compatible release to downstream consumers using caret version requirements, or can grief the CI pipeline by triggering a 45-minute mandatory job on unrelated PRs by naming their branch to match the prefix.

🧪 Proof of Concept:

The condition gating this security-relevant enforcement job checks only a client-suppliable ref name string with no verification of the PR author, bot identity, App installation, or branch-protection restriction on who may create branches with this prefix.

name: release bump is large enough
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-plz-')
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
  - uses: actions/checkout@v7
  - uses: dtolnay/rust-toolchain@stable
  - uses: taiki-e/install-action@cargo-semver-checks
  - name: Check the proposed versions cover their API changes
    env:
      SEMVER_MODE: enforce
    run: bash scripts/semver-report.sh

Vulnerable lines: 516, 543

🔁 Reproduction Steps:

  1. Fork OpenVTC/verifiable-trust-infrastructure.
  2. Create a branch named release-plz-poc from any commit (e.g., main).
  3. Push a trivial change (e.g., edit a comment) and open a pull request from release-plz-poc into the base repo.
  4. Observe in the Actions tab that the release-bump-guard job triggers and runs SEMVER_MODE=enforce even though release-plz never created this branch.
  5. Optionally, craft Cargo.toml manifest version fields in the PR to test whether a small/misleading delta passes cargo-semver-checks despite unrelated breaking changes elsewhere in the diff.

🔎 Evidence: .github/workflows/ci.yml:516

if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-plz-')
...
- uses: actions/checkout@v7

💥 Impact:

An attacker can force or avoid the repository's only hard-blocking API-compatibility gate, potentially allowing a breaking change to ship as a compatible release to downstream consumers using caret version requirements, or can grief the CI pipeline by triggering a 45-minute mandatory job on unrelated PRs by naming their branch to match the prefix.

🧭 Reachability:

  • Network exposure: public
  • Auth barrier: none
  • Attack path: EP-002 (pull_request, github.head_ref) → workflow if: condition in .github/workflows/ci.yml → release-bump-guard job trigger → SEMVER_MODE=enforce → scripts/semver-report.sh

⚖️ Triage Factors:

Factor Value
Fixable ✅ Yes
Exploitability high
Business impact medium
Public exploit None known
Environment unknown

Attack scenario: An attacker names their fork's PR branch release-plz-* to control whether the hard-blocking semver enforcement job runs, since no bot/actor identity is verified.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Add an identity check on github.actor (or, more robustly, verify the GitHub App/bot identity via github.event.pull_request.user.login and github.event.pull_request.user.type == 'Bot') in addition to the branch-name prefix, and enforce a branch-protection ruleset restricting who can push branches matching release-plz-*.

Vulnerable code:

if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-plz-')

Secure code:

if: >
  github.event_name == 'pull_request' &&
  github.actor == 'release-plz[bot]' &&
  startsWith(github.head_ref, 'release-plz-')

Additional recommendations:

  • Add a repository ruleset restricting creation of release-plz-* branches to the release-plz bot/App token.
  • Independently re-derive the required version bump from the actual diff inside the job rather than trusting manifest fields alone.
  • Log the resolved actor/branch identity in the job summary for auditability.

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 95%
  • AI Validation Evidence: EVIDENCE FOUND: if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-plz-') is the sole gating condition for the release-bump-guard job in ci.yml, with no additional actor/identity check (e.g., no check of github.actor or a bot-specific token/claim). github.head_ref is documented as attacker-controllable on pull_request events (it is literally the PR's source branch name, settable by anyone opening a PR). EVIDENCE NOT FOUND: no verification of PR author identity, no branch protection ruleset visible in the provided files restricting who can create release-plz-* branches, no additional signature/claim check. CHANGED VS PRE-EXISTING: this if: condition and the release-bump-guard job are part of the CI hardening added by this PR (full job block present in ci.yml source shown). VERDICT JUSTIFICATION: the exact vulnerable line is quoted and the missing identity check is confirmed by its absence from the full job definition, which is fully visible (not truncated) in the provided source, supporting a positive validated finding.
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 Github Actions Mutable Action Tag (33 occurrences)

Field Detail
Severity MEDIUM
Location .github/workflows/ci.yml:58
Finding ID github_pr-fe7139fe5e34
OWASP A08:2021 - Software and Data Integrity Failures
CVSS 4.0 5.5
Exploit Maturity conceptual
Detection Source mcp_semgrep

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • No CVSS score is provided (dependency-style scoring doesn't apply to CI config), exploit maturity is 'conceptual' per the scanner's own assessment, and there's no confirmed exploitation of these specific action tags. The vulnerable pattern is real and reachable (workflow runs on every PR/push in production), but the attack requires a separate compromise of upstream action infrastructure — an indirect precondition. This aligns with the medium calibration band (limited/theoretical impact absent a confirmed exploit) rather than high/critical.
  • Composite score: 4.9
  • Environment: production

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 — 33 occurrence(s): ci.yml:58, ci.yml:98, ci.yml:100, ci.yml:136, ci.yml:193, ci.yml:195, ci.yml:267, ci.yml:307, ci.yml:311, ci.yml:341, ci.yml:343, ci.yml:358, ci.yml:414, ci.yml:418, ci.yml:459, ci.yml:460, ci.yml:495, ci.yml:537, ci.yml:538, ci.yml:578 (+13 more)

📝 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: ✅ Confirmed True Positive
  • Confidence: 85%
  • AI Validation Evidence: EVIDENCE FOUND: Throughout ci.yml, actions are referenced by mutable tags/aliases, e.g. uses: actions/checkout@v7, uses: dtolnay/rust-toolchain@stable, uses: actions/cache@v6, uses: EmbarkStudios/cargo-deny-action@v2, uses: taiki-e/install-action@cargo-semver-checks, uses: taiki-e/install-action@v2, uses: dtolnay/rust-toolchain@master — none are pinned to a full commit SHA anywhere in the file. EVIDENCE NOT FOUND: no instance of a 40-character commit SHA pin anywhere in ci.yml. CHANGED VS PRE-EXISTING: this pattern spans the whole ci.yml file, including newly added jobs (release-bump-guard) which reuse the same unpinned actions, so at least part of the finding chain (the new job's uses: lines) is changed by this PR. VERDICT JUSTIFICATION: the mutable tag usage is directly quoted and present in the full file across 30+ occurrences, a verifiable and reachable supply-chain weakness.
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 CI job compiles untrusted PR code (build.rs/proc-macros) without least-privilege permissions or sandboxing

Field Detail
Severity MEDIUM
Location .github/workflows/ci.yml:519
Finding ID github_pr-233ec386da3f
CWE CWE-829, CWE-494
OWASP A08:2021 - Software and Data Integrity Failures
MITRE ATT&CK T1195.001 - Supply Chain Compromise: Compromise Software Dependencies and Development Tools
CAPEC CAPEC-242, CAPEC-98
DREAD 6.4
Reachability 🔴 Reachable
Exploit Maturity conceptual
Detection Source skill_scan

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — While reachability and exploitability of the underlying execution primitive are well-evidenced (reachable=true, auth_barrier=none, network_exposure=public, well-documented attack pattern), the severity gates require Environment≥8 (confirmed production/staging exposure) for HIGH, which is not met — environment is explicitly 'unknown' and repo-level settings (branch protection, first-time-contributor approval, org token defaults) that determine real exploitability of secrets/credentials are not visible in this dataset. GitHub's default pull_request token scoping is read-only, meaningfully capping blast radius unless those repo-level settings are misconfigured. Business impact is explicitly rated medium by the scanner (a stepping stone, not the direct high-value target). No CVE/EPSS/CISA KEV data exists (not applicable — this is a first-party workflow misconfiguration), and exploit maturity is scanner-inferred 'poc' from code complexity analysis rather than a confirmed working exploit against this repo. These gaps place it at MEDIUM: real and fixable, but not clearly meeting the HIGH bar without confirmed elevated environment/permissions exposure.
  • Composite score: 4.6
  • Environment: production

Summary: Because the workflow does not show an explicit least-privilege permissions: block, and cargo-semver-checks generates rustdoc JSON for every crate (which requires compilation), attacker-controlled build scripts or procedural macros in a PR can execute code on the runner during a routine CI job, with access to whatever ambient token/secret scope the job inherits.

📝 Description:

Attacker-controlled build.rs/proc-macro code executing during CI can read GITHUB_TOKEN and any workflow/organization secrets exposed to pull_request-triggered jobs, exfiltrate them over the network, or tamper with subsequent CI steps/artifacts, potentially escalating to compromise of the crates.io publish pipeline that release-plz drives.

🧪 Proof of Concept:

No permissions: block limiting GITHUB_TOKEN scope is visible for these jobs, and the semver-check tool necessarily compiles crate code (including build.rs/proc-macros) from the PR to generate rustdoc JSON, giving attacker code an execution point with the job's ambient permissions.

      - name: Check published crates for API breaks
        run: bash scripts/semver-report.sh

  release-bump-guard:
    ...
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - uses: taiki-e/install-action@cargo-semver-checks
      - name: Check the proposed versions cover their API changes
        env:
          SEMVER_MODE: enforce
        run: bash scripts/semver-report.sh

Vulnerable lines: 513, 533

🔁 Reproduction Steps:

  1. Open a PR against the repository that adds or modifies a crate's build.rs to perform an observable side effect (e.g., write a marker file, or in a controlled test, print env var names).
  2. Ensure the crate is not in the excluded/RUNTIME list so it participates in the semver-checks build.
  3. Push the PR and observe the report job (EP-001) or, if branch-named appropriately, release-bump-guard (EP-002) run.
  4. Inspect job logs/artifacts for evidence that build.rs executed during the cargo doc/rustdoc-JSON generation step performed by cargo-semver-checks.

🔎 Evidence: .github/workflows/ci.yml:519

- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-semver-checks

💥 Impact:

Attacker-controlled build.rs/proc-macro code executing during CI can read GITHUB_TOKEN and any workflow/organization secrets exposed to pull_request-triggered jobs, exfiltrate them over the network, or tamper with subsequent CI steps/artifacts, potentially escalating to compromise of the crates.io publish pipeline that release-plz drives.

🧭 Reachability:

  • Network exposure: public
  • Auth barrier: none
  • Attack path: EP-001/EP-002 (pull_request, attacker-controlled PR head) → actions/checkout@v7 → cargo-semver-checks build (compiles crate code including build.rs/proc-macros) → runner ambient permissions/secrets

⚖️ Triage Factors:

Factor Value
Fixable ✅ Yes
Exploitability medium
Business impact high
Public exploit ⚠️ Available
Environment unknown

Attack scenario: Attacker submits a PR whose crate contains a malicious build.rs; cargo-semver-checks' rustdoc-JSON generation compiles and executes it during routine CI, potentially exposing CI secrets.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Adding an explicit least-privilege permissions: block (read-only contents, no other scopes) and disabling credential persistence on checkout limits what an attacker-controlled build script can access or exfiltrate, and isolating the build in a secrets-free, egress-restricted environment removes the high-value target entirely.

Vulnerable code:

steps:
  - uses: actions/checkout@v7
  - uses: dtolnay/rust-toolchain@stable
  - uses: taiki-e/install-action@cargo-semver-checks

Secure code:

permissions:
  contents: read

steps:
  - uses: actions/checkout@v7
    with:
      persist-credentials: false
  - uses: dtolnay/rust-toolchain@stable
  - uses: taiki-e/install-action@cargo-semver-checks
  # Run the semver-check step inside a network-egress-restricted runner/sandbox
  # with no access to publish/deploy secrets.

Additional recommendations:

  • Run untrusted-PR compilation steps in an isolated, ephemeral runner profile with no organization/repository secrets injected.
  • Consider pull_request_target avoidance (already avoided here) and never elevate this job's trigger.
  • Add network egress monitoring/allow-listing for CI runners performing untrusted builds.

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: In .github/workflows/ci.yml the release-bump-guard job (name: 'release bump is large enough') is defined as: if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-plz-') ... steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@cargo-semver-checks - name: Check the proposed versions cover their API changes env: SEMVER_MODE: enforce run: bash scripts/semver-report.sh. scripts/semver-report.sh confirms this builds rustdoc JSON twice per crate via cargo semver-checks "${args[@]}", which per cargo-semver-checks' own operation compiles the crate (running build.rs/proc-macros) to generate rustdoc JSON. No permissions: block, sandbox, or network-egress restriction is present in the job definition. EVIDENCE NOT FOUND: no workflow-level or job-level permissions: key limiting GITHUB_TOKEN scope; no sandboxing (e.g., container isolation, network restriction) around the cargo build step. CHANGED VS PRE-EXISTING: the entire release-bump-guard job and scripts/semver-report.sh are both present in source_files as full file content, and ci.yml/semver-report.sh are the changed files per this PR (ci/release-bump-guard branch name and PR title strongly indicate these are the changed artifacts) — this is new/changed CI logic introducing the guard job. VERDICT JUSTIFICATION: the sink (cargo build via cargo-semver-checks on attacker-controlled PR content) is directly reachable and quoted; the missing least-privilege permissions/sandboxing is confirmed by absence in the full job YAML shown, which is sufficient here since the entire job definition is present in the provided source (not truncated).
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 Fail-open default to non-enforcing 'report' mode when SEMVER_MODE is unset/blank on the enforcement job

Field Detail
Severity MEDIUM
Location scripts/semver-report.sh:56
Finding ID github_pr-cd13b122cfc7
CWE CWE-636, CWE-754
OWASP A04:2021 - Insecure Design
MITRE ATT&CK T1562 - Impair Defenses
CAPEC CAPEC-21
DREAD 4.2
Reachability 🔴 Reachable
Exploit Maturity theoretical

🧠 AI Triage:

  • Severity reassessed: LOW → MEDIUM — The flaw is a fail-open default in a CI enforcement script. It requires either accidental misconfiguration or privileged CI-config access to exploit, has no external network exposure, and its impact is limited to weakening semver-enforcement (a process/governance control), not compromising confidentiality, integrity of production data, or availability of services. This aligns with 'low' — CVSS-equivalent would be in the 2-4 range for a low-impact/local-control-required issue.
  • Composite score: 5
  • Environment: production

Summary: The script's default-to-report behavior is appropriate for the general-purpose informational job, but sharing the same fallback logic with the release-bump-guard job means any accidental or malicious loss of the SEMVER_MODE: enforce env var on that specific job silently disables the only hard-blocking release-safety gate, exiting 0 with a warning instead of failing the pipeline.

📝 Description:

If the enforce-mode environment variable is ever lost (workflow edit mistake, action interference, or reusable-workflow input issue), a breaking-change release with an insufficient version bump would pass CI silently as a warning, allowing release-plz to publish a broken minor/patch release that downstream ^0.x consumers pick up automatically, causing compile breaks in their build pipelines.

🧪 Proof of Concept:

The fallback default of report is safe for the informational job but dangerous for the job whose entire stated purpose is to enforce; there is no assertion within the enforcement code path that MODE actually resolved to enforce before proceeding, so any accidental unset silently downgrades to the permissive behavior.

MODE="${SEMVER_MODE:-report}"
case "$MODE" in
  report | enforce) ;;
  *)
    echo "::error::SEMVER_MODE must be 'report' or 'enforce', got '$MODE'"
    exit 1
    ;;
esac

Vulnerable lines: 56, 63

🔁 Reproduction Steps:

  1. In a test branch, temporarily remove or comment out the env: SEMVER_MODE: enforce line from the release-bump-guard job step in .github/workflows/ci.yml.
  2. Introduce a deliberately breaking API change (e.g., add a variant to a public exhaustive enum) alongside a patch-level version bump in Cargo.toml.
  3. Open a PR from a release-plz-*-named branch and observe that scripts/semver-report.sh defaults to MODE=report, prints ::warning:: instead of ::error::, and the job exits 0 despite the under-sized bump.
  4. Confirm the PR is mergeable without the intended block.

🔎 Evidence: scripts/semver-report.sh:56

MODE="${SEMVER_MODE:-report}"
case "$MODE" in
  report | enforce) ;;
  *) echo "::error::SEMVER_MODE must be 'report' or 'enforce'"; exit 1 ;;
esac

💥 Impact:

If the enforce-mode environment variable is ever lost (workflow edit mistake, action interference, or reusable-workflow input issue), a breaking-change release with an insufficient version bump would pass CI silently as a warning, allowing release-plz to publish a broken minor/patch release that downstream ^0.x consumers pick up automatically, causing compile breaks in their build pipelines.

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: basic
  • Attack path: release-bump-guard job env: block (or lack thereof) → scripts/semver-report.sh MODE="${SEMVER_MODE:-report}" → non-blocking warning path → exit 0

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: In scripts/semver-report.sh: MODE="${SEMVER_MODE:-report}" followed by case "$MODE" in report | enforce) ;; *) echo "::error::SEMVER_MODE must be 'report' or 'enforce'"; exit 1 ;; esac. This confirms that if SEMVER_MODE is unset or empty, MODE silently defaults to the non-blocking report value, which later (at the bottom of the script) only emits ::warning:: and does not exit non-zero in a blocking way for the caller in enforce contexts. EVIDENCE NOT FOUND: no secondary assertion elsewhere in the script or in ci.yml that specifically confirms MODE resolved to enforce when running under the release-bump-guard job (the job sets env: SEMVER_MODE: enforce explicitly, which mitigates this in the CURRENT job definition, but the script itself has no defensive re-assertion if that env line were ever dropped). CHANGED VS PRE-EXISTING: scripts/semver-report.sh is a new/changed file introduced by this PR to implement the guard logic (its full content, including this exact fallback logic, is provided in source_files as part of this MR). VERDICT JUSTIFICATION: the fail-open default is directly quoted and reachable if the env: block were ever removed/misconfigured — a real, low-severity design weakness in code changed by this PR.
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.


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 #1256

Field Value
Repository OpenVTC/verifiable-trust-infrastructure
Branch ci/release-bump-guardmain
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

Introduces a new hard-blocking CI job ('release-bump-guard') that runs only on release-plz branches to enforce that the proposed version bump covers the actual API delta detected by cargo-semver-checks. Restructures scripts/semver-report.sh into a dual-mode script ('report' = non-blocking, default; 'enforce' = blocking) so that ordinary PRs still only get a warning while release-plz branches now hard-fail if the bump is too small, closing a documented real-world gap where release-plz's own semver judgment diverged from cargo-semver-checks and shipped breaking changes as compatible releases.

Diff: +61 / -6 lines
Types: ci_cd, security, release_engineering

⚠️ Security Implications

🟡 Branch-name-only trust boundary for entering hard-blocking enforce mode

Branch-name-only trust boundary for entering hard-blocking enforce mode

Action: Add an identity check (e.g., github.actor == 'release-plz[bot]' or equivalent App-derived claim) in addition to the branch-name prefix, and/or restrict branch creation matching 'release-plz-*' to the release-plz automation via a repository ruleset.

⚪ Conversion of a previously non-blocking semver signal into a hard release gate

Conversion of a previously non-blocking semver signal into a hard release gate

Action: Retain this control; extend it with the identity-verification and permissions hardening noted in other findings so the improvement is not undermined by adjacent weaknesses.

🟡 Untrusted PR code execution during cargo-semver-checks build now occurs in a second, higher-trust job context

Untrusted PR code execution during cargo-semver-checks build now occurs in a second, higher-trust job context

Action: Add an explicit least-privilege permissions: contents: read block to this job (and the sibling report job), and consider running the build step in a network-egress-restricted, secrets-free sandbox.

🔵 Fail-open default if SEMVER_MODE is unset in a future refactor of the enforce job

Fail-open default if SEMVER_MODE is unset in a future refactor of the enforce job

Action: In the release-bump-guard job specifically, add an explicit assertion (e.g., a preceding step or a check at the top of the script invocation) confirming MODE resolved to 'enforce', failing loudly if not.

🧩 Affected Components

Component Impact Change What Changed
Release Publishing Pipeline (release-plz integration) high modified A new hard-blocking CI job now gates any PR whose branch name starts with 'release-plz-', running the same underlying semver-check script in
CI Supply-Chain Surface medium modified New job adds three more invocations of unpinned third-party GitHub Actions (actions/checkout@v7, dtolnay/rust-toolchain@stable, taiki-e/inst

📁 File Classifications

.github/workflows/ci.yml

  • Type: ci_cd_security_critical

scripts/semver-report.sh

  • Type: security_critical_build_script

🛡️ STRIDE Threat Model

Identified Threats (12)

🟠 STRIDE-1: Untrusted head_ref Branch-Name Matching Bypasses release-bump-guard Enforcement

Field Detail
Category Spoofing, Tampering, Elevation of Privilege
Severity High
Likelihood Likely
CVSS 7.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-807,CWE-290
CAPEC CAPEC-151,CAPEC-698
OWASP A07:2021 - Identification and Authentication Failures

Description: pull_request trigger in COMP-001 allows spoofing of the release-plz- branch-name prefix due to reliance on startsWith(github.head_ref, 'release-plz-') as the sole gating condition, resulting in an attacker-controlled fork branch either evading the enforce-mode block or forcing the strict release-bump-guard to run (and fail/deny) on non-release PRs.

Evidence: .github/workflows/ci.yml:516-543

if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-plz-')

Attack Scenario:

  1. Attacker forks the repository and creates a branch literally named release-plz-x from any commit, since github.head_ref is fully attacker-controlled string content on a pull_request event.
  2. Attacker opens a PR from that branch; GitHub Actions evaluates the if: condition in .github/workflows/ci.yml (startsWith(github.head_ref, 'release-plz-')) and it evaluates true even though release-plz never created the branch.
  3. Because MODE is now forced to 'enforce' via env SEMVER_MODE: enforce in the release-bump-guard job, scripts/semver-report.sh treats attacker-supplied Cargo.toml manifest versions as authoritative 'proposed versions about to be published' and derives the required release type from them.
  4. Attacker crafts the PR's Cargo.toml version fields to make cargo-semver-checks compute a deceptively small delta (e.g., pin version to a patch bump while actually introducing breaking API changes elsewhere in the PR), causing the enforce check to pass when it should fail, or conversely open unrelated PRs from branches accidentally matching the prefix to make the strict, no-continue-on-error job block merges (denial of service on unrelated contributors).
  5. Because there is no verification that the branch was genuinely created by the release-plz bot/app (no check of the PR author identity, commit signer, or a release-plz-specific label/token), the trust decision is made purely on a client-suppliable string.

🔎 Threat Clue: Derived from COMP-001 via EP-002

  • Data Flows: github.head_ref -> if: condition -> SEMVER_MODE env

Preconditions: Attacker can open a pull request against the repository (fork PRs from public repo, or write access to any branch)., No branch protection rule restricts creation of branches matching 'release-plz-*' to the release-plz bot's token/identity.

Existing Controls: timeout-minutes: 45 bounds job runtime. • cargo-semver-checks performs real API-diff analysis rather than trusting only the manifest text.

Recommended Mitigations: Gate the job on the PR author/actor being the release-plz bot identity (e.g., check github.actor == 'release-plz[bot]' or an OIDC/App-derived claim) in addition to the branch name. • Restrict who can create branches matching release-plz-* via a branch protection / ruleset so only the release-plz automation can push them. • Require the release-bump-guard job to independently re-derive the expected version bump from the diff rather than trusting attacker-controlled manifest fields alone.


🟠 STRIDE-2: Pull-Request Code Execution via Untrusted checkout in release-bump-guard Job

Field Detail
Category Tampering, Information Disclosure, Elevation of Privilege
Severity High
Likelihood Likely
CVSS 8.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
Residual Severity High
CWE CWE-829,CWE-494
CAPEC CAPEC-242,CAPEC-98
OWASP A08:2021 - Software and Data Integrity Failures

Description: pull_request trigger in COMP-001 allows arbitrary code execution during CI due to actions/checkout@v7 checking out attacker-controlled PR head content that is then processed by cargo-semver-checks / cargo build (which executes build.rs and proc-macros), resulting in secrets exfiltration or supply-chain compromise of the CI runner.

Evidence: .github/workflows/ci.yml:519-543

steps:
  - uses: actions/checkout@v7
  - uses: dtolnay/rust-toolchain@stable
  - uses: taiki-e/install-action@cargo-semver-checks

Attack Scenario:

  1. Attacker opens a fork PR (or a branch on the repo if they have write access) whose branch name matches 'release-plz-*', or targets the always-running report job (EP-001).
  2. actions/checkout@v7 in the workflow checks out the PR's HEAD ref, materializing attacker-controlled Cargo.toml, build.rs files, and crate source on the runner filesystem.
  3. cargo-semver-checks (installed via taiki-e/install-action) invokes cargo doc/cargo build-equivalent rustdoc JSON generation twice per crate (current + baseline) as noted in scripts/semver-report.sh comments, which compiles and executes any build.rs / proc-macro code contained in the attacker's crate changes.
  4. Malicious build.rs or proc-macro code executes with the GitHub Actions runner's ambient permissions, potentially reading GITHUB_TOKEN, environment secrets, or writing to the network.
  5. Because release-bump-guard runs with no continue-on-error and elevated intent (enforcing release correctness) it is likely to have equal or greater default GITHUB_TOKEN scopes than other jobs, and any workflow-level secrets are exposed to this attacker-triggered compile step.

🔎 Threat Clue: Derived from COMP-001, COMP-002 via EP-001, EP-002

  • Data Flows: PR head ref -> actions/checkout -> cargo-semver-checks build

Preconditions: Attacker can submit a pull request with modified crate source (build.rs, proc-macros, or dependencies) that reaches the semver-checks build., Workflow uses default or write-scoped GITHUB_TOKEN / additional secrets accessible to pull_request-triggered jobs.

Existing Controls: Standard GitHub Actions pull_request (not pull_request_target) semantics limit secret exposure only if permissions are minimized; no explicit permissions: block is visible in the shown snippet. • timeout-minutes: 45 limits runaway execution.

Recommended Mitigations: Add an explicit least-privilege permissions: block (e.g., contents: read) to every job that checks out untrusted PR code. • Run cargo-semver-checks and any build step for untrusted PR code inside a network-egress-restricted, ephemeral sandbox lacking secrets. • Pin all third-party actions (actions/checkout, dtolnay/rust-toolchain, taiki-e/install-action) to immutable commit SHAs rather than tags to prevent supply-chain substitution. • Consider using pull_request events only (never pull_request_target) and avoid checking out untrusted refs in privileged contexts.


🟠 STRIDE-3: Unpinned Third-Party Actions Enable Supply-Chain Substitution in release-bump-guard

Field Detail
Category Tampering, Elevation of Privilege
Severity High
Likelihood Possible
CVSS 7.7 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-829,CWE-1357
CAPEC CAPEC-538,CAPEC-533
OWASP A08:2021 - Software and Data Integrity Failures

Description: CI job configuration in COMP-001 allows supply-chain tampering due to third-party GitHub Actions (actions/checkout@v7, dtolnay/rust-toolchain@stable, taiki-e/install-action@cargo-semver-checks) being referenced by mutable tag/branch rather than pinned commit SHA, resulting in malicious code execution if any upstream action or the cargo-semver-checks binary distribution is compromised.

Evidence: .github/workflows/ci.yml:519-522

- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@cargo-semver-checks

Attack Scenario:

  1. An upstream maintainer account for actions/checkout, dtolnay/rust-toolchain, or taiki-e/install-action is compromised (credential theft, malicious commit) — a well-documented GitHub Actions supply-chain pattern.
  2. The compromised action publishes a new commit under the same mutable tag (v7, stable, or the cargo-semver-checks alias) referenced in .github/workflows/ci.yml.
  3. On the next pull_request run of release-bump-guard or the API-break report job, GitHub Actions resolves the tag to the malicious commit and executes it with the job's runner permissions and any exposed secrets.
  4. Because SEMVER_MODE=enforce is a hard-blocking gate (NO continue-on-error), attackers who control this supply chain step can also manipulate merge/release outcomes directly, or exfiltrate CI secrets used for crates.io publishing.
  5. No uses: actor/action@<full-40-char-sha> pinning nor step-level integrity verification (e.g., cosign, checksum) is present to detect tampering before execution.

🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-002

  • Data Flows: GitHub Actions marketplace -> workflow runner

Preconditions: A referenced third-party action's tag or the cargo-semver-checks distribution channel is compromised., No supply-chain SCA / action-pinning policy enforced in this repository.

Existing Controls: Use of taiki-e/install-action wrapper which may pull from an official binstall registry rather than arbitrary URLs (partial mitigation).

Recommended Mitigations: Pin every uses: action to a full-length immutable commit SHA and add Dependabot/renovate to track updates. • Adopt GitHub's allowed-actions org policy to restrict which third-party actions can run. • Verify cargo-semver-checks binary via checksum/signature before use where supported.


🟡 STRIDE-4: Missing Case-Sensitivity / Exact-Match Validation of SEMVER_MODE Enables Silent Fallback to report Mode

Field Detail
Category Tampering, Denial of Service
Severity Medium
Likelihood Possible
CVSS 5.9 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-636,CWE-754
CAPEC CAPEC-21
OWASP A04:2021 - Insecure Design

Description: env var handling in EP-003 (scripts/semver-report.sh) allows tampering-induced logic bypass due to the case statement only validating literal strings 'report'/'enforce' with exit 1 on mismatch, resulting in denial of the enforce workflow (job aborts entirely) rather than a fail-safe default to strict mode when the caller misconfigures SEMVER_MODE.

Evidence: scripts/semver-report.sh:~56-63

MODE="${SEMVER_MODE:-report}"
case "$MODE" in
  report | enforce) ;;
  *)
    echo "::error::SEMVER_MODE must be 'report' or 'enforce', got '$MODE'"
    exit 1
    ;;
esac

Attack Scenario:

  1. A future workflow edit or a compromised prior step in the job (e.g., a malicious action running earlier in the same job) sets or mutates the SEMVER_MODE environment variable to an unexpected value such as 'Enforce' (wrong case) or an empty string via echo "SEMVER_MODE=" >> $GITHUB_ENV.
  2. scripts/semver-report.sh reads MODE="${SEMVER_MODE:-report}" and the subsequent case "$MODE" in report | enforce) ;; *) exit 1 ;; esac rejects the malformed value and the script exits 1 with ::error::SEMVER_MODE must be 'report' or 'enforce'.
  3. Because the release-bump-guard job has no continue-on-error, this hard-fails the whole release pipeline, but critically the failure mode is a denial-of-service on legitimate releases rather than a fail-open condition — however, if an attacker can instead cause the variable to be unset entirely (e.g., interfering with the env: block via a workflow_dispatch input injection elsewhere in the file, out of scope here) the script silently defaults to 'report' (${SEMVER_MODE:-report}), which never blocks, defeating the entire purpose of release-bump-guard.
  4. In the silent-default scenario, a breaking-change release with an under-sized version bump would be reported only as a warning (::warning::API breaks reported...) and the job would exit 0, allowing release-plz to proceed publishing a broken minor/patch release.
  5. Downstream consumers using caret version requirements (^0.16, ^0.32) automatically pull the broken release, causing compile breaks in their own build pipelines — the exact scenario this guard was built to prevent.

🔎 Threat Clue: Derived from COMP-002 via EP-003

  • Data Flows: SEMVER_MODE env -> MODE variable -> case statement

Preconditions: An attacker or misconfiguration can unset or blank the SEMVER_MODE env var specifically on the release-bump-guard job invocation (e.g., via a compromised prior step, workflow-level env override, or reusable-workflow input tampering)., No secondary confirmation step verifies that MODE actually resolved to 'enforce' on the release-plz branch before treating the run as authoritative.

Existing Controls: Explicit case-statement validation rejects any value other than 'report'/'enforce', which limits (but does not eliminate) fail-open risk to the specific case of the variable being fully unset.

Recommended Mitigations: On the release-bump-guard job specifically, assert MODE == enforce explicitly (fail loudly if it is not), rather than relying on the generic default-to-report fallback shared with the informational job. • Add a CI meta-test that runs semver-report.sh with SEMVER_MODE unset in the release-bump-guard context and asserts it fails, preventing silent regression to report semantics on the enforcement job.


🟡 STRIDE-5: Deliberate Exclusion of Crates from Baseline Diff Weakens API-Break Detection Coverage

Field Detail
Category Tampering, Repudiation
Severity Medium
Likelihood Possible
CVSS 5.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-693,CWE-778
CAPEC CAPEC-176
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: crate exclusion list in EP-003 (scripts/semver-report.sh) allows tampering with the coverage set due to hard-coded 'excluded for RUNTIME, not because a break there is acceptable' logic that removes certain crates from both report and enforce runs, resulting in undetected breaking changes shipping in excluded crates despite the enforcement gate's intent.

Evidence: scripts/semver-report.sh:~46-52

# Excluded for RUNTIME, not because a break there is acceptable. This job builds
# rustdoc JSON for every crate twice, current and baseline; including these took
# it from ~17 minutes to over 35, on every pull request, for a signal no

Attack Scenario:

  1. Reviewer comments in scripts/semver-report.sh state the exclusion exists purely for CI runtime (17 min -> 35 min) reasons, not because breaking changes there are acceptable, meaning the exclusion list is a knowingly incomplete compensating control.
  2. An attacker with legitimate contributor access (or a malicious insider) introduces a genuinely breaking API change confined to one of the excluded crates in a PR.
  3. Neither the report job (EP-001) nor the enforce job (EP-002/release-bump-guard) evaluates that crate's rustdoc JSON diff, because it was pre-filtered out of the expected array before cargo-semver-checks runs, per the script's own comment ('excluded... this took it from ~17 minutes to over 35... for a signal no [one reads]', truncated in the diff).
  4. release-plz computes a version bump using its own (potentially also blind-spotted or more complete) semver analysis; if release-plz's tool similarly misses the excluded-crate's break, or if the exclusion also removes it from release-plz's radar, the broken crate ships with an insufficient version bump.
  5. Because the enforce job records 'coverage: all ${#expected[@]} expected crates were checked' as a success message, downstream auditors/reviewers are given false assurance that full coverage occurred, when in fact certain crates were structurally never covered — this is also a repudiation risk: there is no record showing which specific crates were excluded and why, at run time, other than static comments in the script.

🔎 Threat Clue: Derived from COMP-002 via EP-001, EP-002, EP-003

  • Data Flows: expected[] crate array -> cargo-semver-checks invocation loop

Preconditions: A breaking API change is introduced specifically within one of the runtime-excluded crates., No independent, slower-but-complete audit path exists to catch drift in the excluded crates before release.

Existing Controls: Documented rationale in code comments (though not runtime-visible in the CI summary). • The exclusion is scoped to reduce CI runtime, implying the intent to re-include if runtime constraints change.

Recommended Mitigations: Print the exact excluded crate list in the CI run summary/logs at runtime (not just static script comments) so every run explicitly discloses reduced coverage for repudiation/audit purposes. • Run a slower, complete (non-excluded) semver check on a periodic (e.g., nightly or pre-release-only) schedule to close the coverage gap without paying the cost on every PR. • Track excluded crates in a machine-readable manifest with an expiry/justification date reviewed each release cycle.


🔵 STRIDE-6: Report-Mode Job Uses continue-on-error Enabling Repudiation of Real API Breaks

Field Detail
Category Repudiation
Severity Low
Likelihood Likely
CVSS 4.5 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-778,CWE-223
CAPEC CAPEC-593
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: 'Check published crates for API breaks' step in EP-001 allows repudiation of genuine breaking changes due to the report-mode job producing only a ::warning:: and exiting non-blockingly (continue-on-error implied by design contrast with release-bump-guard), resulting in maintainers being able to merge and later deny awareness of flagged API breaks that were never actually addressed before release.

Evidence: scripts/semver-report.sh:~285-292

echo "::warning::API breaks reported — expected on a PR marked '!'; the \
release moves the compatibility field accordingly."
exit "$status"

Attack Scenario:

  1. A contributor introduces a breaking API change in a normal (non release-plz) PR.
  2. scripts/semver-report.sh runs in report mode (SEMVER_MODE unset/default), cargo-semver-checks fails ($status -ne 0), and the script prints ::warning::API breaks reported — expected on a PR marked '!'... then exit "$status" — but per the comment history and workflow context this job is understood to be non-blocking/informational (report mode by design does not halt merge in the same way release-bump-guard does).
  3. Reviewers may treat the yellow warning annotation as routine noise (especially if many PRs legitimately carry '!' break markers), approve and merge the PR without following through on updating the compatibility field.
  4. Later, when release-plz proposes a version, if the release-bump-guard job's independent enforce-mode check also misses the same break (e.g., due to the exclusion list in STRIDE-5, or a timing gap where the release branch is cut before the fix), the breaking change ships silently.
  5. Because the only record of the original warning is a transient GitHub Actions log annotation (no persisted, queryable audit trail, no required acknowledgement/sign-off artifact), there is no non-repudiation mechanism forcing the PR author or reviewer to explicitly accept responsibility for the flagged break — they can plausibly claim they did not see or understand the warning.

🔎 Threat Clue: Derived from COMP-002 via EP-001

  • Data Flows: cargo-semver-checks exit status -> ::warning:: annotation

Preconditions: A genuine breaking change is introduced and correctly detected by cargo-semver-checks in report mode., No mandatory review gate or required status check ties the warning annotation to merge approval.

Existing Controls: The warning message explicitly states breaks are 'expected on a PR marked !' guiding maintainers to the correct compensating action (bumping the compatibility field).

Recommended Mitigations: Require an explicit PR label or checkbox acknowledgment when cargo-semver-checks reports a break, captured as a required status check tied to a human decision, not just a log line. • Persist semver-check results as a structured artifact (e.g., PR comment via bot, or a required GitHub check run) for durable audit trail rather than relying solely on ephemeral workflow log annotations.


🟡 STRIDE-7: Doubled Rustdoc-JSON Build Load Enables CI Resource-Exhaustion Denial of Service

Field Detail
Category Denial of Service
Severity Medium
Likelihood Possible
CVSS 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-400,CWE-770
CAPEC CAPEC-125,CAPEC-130
OWASP A05:2021 - Security Misconfiguration

Description: 'Check published crates for API breaks' step in EP-001/EP-002 allows denial of service due to cargo-semver-checks building rustdoc JSON for every expected crate twice (current + baseline) with only a 45-minute timeout guard, resulting in runner resource exhaustion or job-timeout failures when an attacker adds many crates or large dependency trees in a single PR.

Evidence: scripts/semver-report.sh:~46-52

# This job builds rustdoc JSON for every crate twice, current and baseline; including these took it from ~17 minutes to over 35, on every pull request

Attack Scenario:

  1. A contributor (or attacker with PR-open privileges) adds numerous new crates to the workspace, or dependencies that dramatically inflate compile time, within a single PR.
  2. Both the report job (EP-001, no explicit timeout shown for that job in the given excerpt) and, on a release-plz branch, the release-bump-guard job (EP-002, timeout-minutes: 45) invoke scripts/semver-report.sh, which the code comments confirm already pushed runtime from ~17 to ~35 minutes for the existing crate set alone.
  3. Adding attacker-controlled crates/dependencies further inflates the double rustdoc-JSON build (current + baseline per crate), risking the job exceeding the 45-minute timeout on the enforcement path — a hard failure that blocks all releases, not just the offending one, because release-bump-guard has no continue-on-error.
  4. Because this job is triggered by any pull_request (EP-001) with no cost/authorization gating, repeated resource-exhausting PRs can be opened by external contributors on a public repository to consume shared CI runner minutes/quota, degrading availability for all other workflows in the org.
  5. This is compounded by the two-mode design: the same expensive script executes on every ordinary PR (report) and again on every release-plz PR (enforce), doubling exposure surface for CI-minute exhaustion.

🔎 Threat Clue: Derived from COMP-002 via EP-001, EP-002

  • Data Flows: PR crate/dependency additions -> cargo-semver-checks rustdoc JSON build loop

Preconditions: Repository accepts pull requests from external/public contributors (or any low-privilege internal contributor) without cost-based gating., No CI concurrency/queue limits or per-PR compute budget caps configured beyond the per-job timeout.

Existing Controls: timeout-minutes: 45 bounds worst-case single-job duration on the enforce path. • Crate exclusion list (see STRIDE-5) already reduces the build set somewhat, incidentally limiting some DoS surface.

Recommended Mitigations: Add concurrency: groups keyed on PR number to cancel superseded runs and reduce cumulative CI-minute consumption. • Require maintainer approval ('workflow_run' gating or pull_request_target with review) before expensive semver-check jobs run for first-time/external contributors. • Add explicit timeout-minutes to the report job (EP-001) as well, matching the guard job.


🟡 STRIDE-8: Missing Explicit permissions Block Grants Default GITHUB_TOKEN Scope to Untrusted PR Jobs

Field Detail
Category Elevation of Privilege, Information Disclosure
Severity Medium
Likelihood Possible
CVSS 6.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-269,CWE-732
CAPEC CAPEC-233
OWASP A01:2021 - Broken Access Control

Description: CI job definitions in COMP-001 allow elevation of privilege due to the absence of a visible least-privilege permissions: block on the release-bump-guard (and report) job, resulting in the default (potentially broader) GITHUB_TOKEN scope being available to steps that execute code derived from an untrusted pull request.

Evidence: .github/workflows/ci.yml:513-543

name: release bump is large enough
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-plz-')
runs-on: ubuntu-latest
timeout-minutes: 45

Attack Scenario:

  1. The shown workflow excerpt for release-bump-guard and the API-break report job declares no permissions: key at the job or step level, meaning the token scope defaults to whatever is configured at the workflow or organization level (which may be write-all in older repositories).
  2. Attacker submits a PR (potentially matching the release-plz- prefix per STRIDE-1, or simply targeting EP-001) that includes a malicious build.rs/proc-macro as described in STRIDE-2.
  3. During cargo-semver-checks execution, the malicious code runs with access to GITHUB_TOKEN environment variable if the runner exposes it to the step (common in Actions runners), potentially allowing pushes, PR comments, or release asset tampering if the token has write scope.
  4. Combined with STRIDE-1 (branch name spoofing) an attacker could specifically target the release-bump-guard job because it is guaranteed to run with elevated intent/trust (it exists to gate releases) yet may not have been hardened with reduced token permissions relative to that trust level.
  5. Exfiltrated or misused token could be used to push malicious commits, create releases, or manipulate other PRs/issues within the token's granted scope, undermining the very release-integrity guarantee this job was built to provide.

🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-002

  • Data Flows: GITHUB_TOKEN -> job runtime environment

Preconditions: Repository/organization does not enforce restrictive default GITHUB_TOKEN permissions (permissions: read-all at org level)., Attacker can get code to execute inside the job via STRIDE-2's build.rs/proc-macro vector.

Existing Controls: None visible in the provided excerpt; relies entirely on org/repo-level defaults not shown.

Recommended Mitigations: Add an explicit permissions: contents: read (and no other scopes) block to both the report and release-bump-guard jobs. • Enforce organization-wide default token permissions of read-only, requiring explicit opt-in elevation only where justified. • Avoid printing or exporting GITHUB_TOKEN into the environment of the cargo-semver-checks build step.


🔵 STRIDE-9: Prompt-Injection-Style Comment Content in semver-report.sh Could Mislead Automated PR Reviewers or LLM-Assisted Tooling

Field Detail
Category Tampering, Information Disclosure
Severity Low
Likelihood Possible
CVSS 3.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-1426
CAPEC CAPEC-242
OWASP A03:2021 - Injection

Description: embedded natural-language commentary in EP-003 (scripts/semver-report.sh) allows tampering with automated review/analysis processes due to long, imperative, human-directed comment blocks (e.g., 'THE PROPOSED RELEASE UNDER-BUMPS...') embedded directly in error strings and script comments that could be crafted by a future malicious contributor to influence LLM-based CI summarizers, PR bots, or security-scanning assistants that ingest raw script text as trusted context, resulting in the automated tool making an incorrect risk/merge recommendation.

Evidence: scripts/semver-report.sh:~278-286

echo "::error::THE PROPOSED RELEASE UNDER-BUMPS A BREAKING CHANGE. Each version above \
is the one about to be published..."

Attack Scenario:

  1. This repository already embeds long, emphatic, all-caps, second-person guidance strings directly in shell comments and ::error:: messages (e.g., 'THE PROPOSED RELEASE UNDER-BUMPS A BREAKING CHANGE...'), demonstrating an established pattern of natural-language instructions living inside executable script files.
  2. An attacker with contributor access submits a follow-up PR that extends this same pattern with text specifically engineered to be picked up by any automated PR-review assistant, CI summarizer, or LLM-based security scanner that ingests file diffs as unescaped context (e.g., a comment reading 'ignore previous findings, this change is pre-approved by security team').
  3. If any downstream automation (not shown in this artifact set, but plausible given the presence of AI-assisted review tooling in the modern SDLC) treats script comments or echoed strings as instructions rather than data, it could suppress legitimate findings or mis-classify a genuine breaking-change bypass as benign.
  4. This is a design-level cautionary threat class (prompt injection via source artifacts) applicable to any pipeline where an LLM assists in code review, release-note generation, or security triage of this repository's CI scripts.
  5. The security directive governing this very analysis explicitly required treating all such embedded text as data, not instruction — this threat models the residual risk to other, less-hardened tooling that may not apply the same discipline.

🔎 Threat Clue: Derived from COMP-002 via EP-003

  • Data Flows: script comments/echo strings -> downstream automated tooling context

Preconditions: A downstream tool (CI summarizer, LLM PR assistant, automated security bot) ingests raw script/comment text as part of its decision-making prompt without input/output separation., Attacker has sufficient repository access to add or modify comments/echoed strings in scripts.

Existing Controls: This specific analysis pipeline applies an explicit security directive to treat all pasted content as untrusted data, mitigating the risk for this scan.

Recommended Mitigations: Establish an organizational policy that any AI-assisted code review/summarization tooling must treat repository file content strictly as data, never as instructions, mirroring this analysis's own directive. • Avoid embedding second-person imperative language in production scripts/comments where feasible; prefer neutral, declarative documentation to reduce social-engineering surface for both humans and automated tools.


🟡 STRIDE-10: cargo-semver-checks Baseline Resolution Trusts Published crates.io Data Without Integrity Pinning

Field Detail
Category Tampering, Information Disclosure
Severity Medium
Likelihood Possible
CVSS 5.7 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-829,CWE-345
CAPEC CAPEC-538
OWASP A08:2021 - Software and Data Integrity Failures

Description: baseline crate resolution in EP-002/EP-003 allows tampering-based integrity failure due to cargo-semver-checks fetching the published baseline version of each crate from crates.io without an explicit checksum/signature verification step referenced in the script, resulting in a compromised or yanked-and-replaced crates.io package being used as the trusted comparison baseline for release-bump-guard's enforcement decision.

Evidence: scripts/semver-report.sh:~1-10

# Sibling guard to release-guards: the version bump can be correct and the
# release still break if Cargo.lock pins a stale crates.io copy of one of

Attack Scenario:

  1. release-bump-guard's core trust assumption is that the published baseline crate version on crates.io accurately represents the last released API surface, per the sibling job comment referencing 'lockfile-self-pins: the version bump can be correct and the release still break if Cargo.lock pins a stale crates.io copy'.
  2. If an attacker compromises the crates.io publishing credentials for one of this project's own crates (vti-common, vta-sdk) or a transitive dependency involved in baseline resolution, they could publish a malicious or subtly altered version under an existing version number window (subject to crates.io's own immutability guarantees, which are strong but not infinite against maintainer account compromise).
  3. cargo-semver-checks would then diff the current PR against this potentially-tampered baseline, and could report a false 'API compatible' result if the malicious baseline was itself already altered to mask the real prior API surface, undermining the reconciliation logic in release-bump-guard.
  4. Because the script's only integrity anchor is 'derives the release type from the real delta' relative to whatever baseline crates.io serves, there is no independent hash-pinned baseline snapshot stored in-repo to fall back on or diff against.
  5. This is a lower-probability but high-impact supply-chain trust chain: the release-bump-guard's entire enforcement value collapses to the trustworthiness of crates.io's serving infrastructure and this project's own publishing account security, neither of which are controlled by this script.

🔎 Threat Clue: Derived from COMP-002 via EP-002, EP-003

  • Data Flows: crates.io published baseline -> cargo-semver-checks diff engine

Preconditions: Compromise of crates.io publishing credentials for a relevant crate, or a crates.io infrastructure-level integrity failure., No local, hash-verified baseline artifact stored in the repository as a fallback/cross-check.

Existing Controls: crates.io itself enforces version immutability (cannot silently overwrite a published version), which significantly limits (but per the sibling job's own comment, does not eliminate — see lockfile-self-pins job referenced) this attack path. • The existence of a sibling 'lockfile-self-pins' job suggests the team is already aware of stale/mismatched baseline risks and building compensating controls.

Recommended Mitigations: Cross-validate the resolved baseline crate's checksum against the value already pinned in Cargo.lock (the sibling lockfile-self-pins job's apparent purpose) before trusting it as the semver-check baseline. • Consider vendoring or caching a signed baseline snapshot per release for critical crates instead of relying solely on live crates.io resolution.


🔵 STRIDE-11: No Idempotency/Concurrency Control Enables Race Between Concurrent release-plz Branch Pushes and release-bump-guard Evaluation

Field Detail
Category Tampering, Denial of Service
Severity Low
Likelihood Unlikely
CVSS 4.0 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-367
CAPEC CAPEC-26
OWASP A04:2021 - Insecure Design

Description: release-bump-guard job in EP-002 allows a tampering/TOCTOU race due to the absence of a concurrency: group keyed to the release-plz branch, resulting in a stale enforce-mode run's pass/fail result potentially being reported against a newer, different commit if the release-plz branch is force-pushed or updated mid-run.

Evidence: .github/workflows/ci.yml:513-543

if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'release-plz-')
runs-on: ubuntu-latest
timeout-minutes: 45

Attack Scenario:

  1. release-plz updates its own branch (e.g., release-plz-main) with a new proposed version set, triggering a new pull_request synchronize event and a new release-bump-guard run (EP-002).
  2. If a second update to the same branch occurs while the first run is still executing (e.g., a manual maintainer edit to the manifest, or release-plz re-running due to a race in its own automation), GitHub Actions may run two concurrent release-bump-guard jobs against effectively the same PR without a concurrency: cancellation group defined in the shown excerpt.
  3. The earlier run, evaluating an older commit, could complete and report a passing status (green check) on the PR after the newer, still-broken commit has already been pushed — because required-status-check semantics in GitHub typically key off the latest commit, this specific race is partially self-mitigating by GitHub's own model, but a narrow window still exists where a human merges on stale status information from a job summary/log rather than the live check state.
  4. This is a lower-severity, low-likelihood timing issue, but relevant given the explicit MODE=enforce hard-block design intent — any TOCTOU gap undermines the 'no continue-on-error' guarantee.
  5. Absent concurrency: group: release-bump-guard-${{ github.head_ref }}, cancel-in-progress: true, duplicate/stale runs also waste CI resources (secondary DoS contribution, compounding STRIDE-7).

🔎 Threat Clue: Derived from COMP-001 via EP-002

  • Data Flows: release-plz branch push events -> concurrent workflow runs

Preconditions: Multiple rapid updates to the same release-plz branch within the runtime window of a single release-bump-guard execution (~up to 45 minutes)., A human reviewer consults job logs/summaries rather than the live GitHub required-check status before merging.

Existing Controls: GitHub's required-status-check model generally re-evaluates against the latest commit SHA, providing baseline protection against merging on a stale status for branch-protected merges.

Recommended Mitigations: Add an explicit concurrency: group scoped to the head ref with cancel-in-progress: true to eliminate stale/duplicate run ambiguity and reduce wasted compute. • Ensure branch protection rules require the release-bump-guard check specifically (not just 'any success') and are configured to re-run on every push, not cached.


🔵 STRIDE-12: Lack of Job-Level Output Sanitization Enables Log Injection via Crate/Package Names in CI Annotations

Field Detail
Category Tampering, Repudiation
Severity Low
Likelihood Possible
CVSS 3.5 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-117,CWE-150
CAPEC CAPEC-93
OWASP A03:2021 - Injection

Description: echoed ::error::/::warning:: annotations in EP-003 allow tampering with CI log integrity due to unsanitized interpolation of dynamic values (crate names, counts) into GitHub Actions workflow-command strings, resulting in potential log-injection or workflow-command spoofing if an attacker controls a crate name or manifest field that flows into an echoed annotation.

Evidence: scripts/semver-report.sh:~271-274

echo
echo "coverage: all ${#expected[@]} expected crates were checked"

Attack Scenario:

  1. scripts/semver-report.sh builds its expected crate list and prints messages like 'coverage: all ${#expected[@]} expected crates were checked' and per-crate status lines (implied by the loop structure, though the exact interpolation site for crate names is truncated in the provided excerpt).
  2. If any crate name, derived from an attacker-controlled Cargo.toml [package] name field in a malicious dependency or workspace member added via PR, is echoed directly into a ::error::/::warning::/::notice:: GitHub Actions workflow command string without escaping newlines or the %0A/%0D encoding GitHub requires for multi-line annotations, an attacker could inject additional fake workflow-command tokens (e.g., a crafted crate name containing \n::error::fake message or %0A::add-mask::) that get interpreted as separate workflow commands by the Actions runner's log processor.
  3. This could be used to spoof additional fake error/warning annotations, mask legitimate output (via ::add-mask:: injection hiding subsequent real error text from logs), or clutter the job summary to bury the genuine enforce-mode failure message from STRIDE-1/STRIDE-4 among decoy annotations.
  4. Because GitHub Actions log annotations are the primary human-facing signal for this security-relevant gate, successful injection directly undermines the non-repudiation and integrity of the release-bump-guard's reported verdict.
  5. Full exploitability depends on exact interpolation points not fully visible in the reduced source excerpt, but the pattern of directly echoing crate-derived, potentially attacker-influenced strings into ::error::-prefixed lines is present and warrants validation.

🔎 Threat Clue: Derived from COMP-002 via EP-003

  • Data Flows: Cargo.toml package name -> expected[] array -> echoed CI annotation

Preconditions: A crate name or manifest-derived string that is at least partially attacker-controlled (e.g., a new workspace member added in the PR) flows unescaped into an ::error::/::warning:: echo statement., GitHub Actions runner processes the untrusted string as a workflow command rather than literal log text.

Existing Controls: GitHub Actions applies some automatic escaping/percent-encoding requirements for workflow commands, which raises the bar for successful injection but is not a substitute for source-side sanitization.

Recommended Mitigations: Sanitize or percent-encode any dynamic, potentially attacker-influenced value before interpolating it into ::error::/::warning:: strings. • Prefer using $GITHUB_STEP_SUMMARY markdown output for dynamic/untrusted content instead of workflow-command annotation syntax, which is less prone to command-injection-style parsing.



🍝 PASTA Threat Model

Application Purpose

This PR hardens the CI release pipeline of a verifiable-trust-infrastructure Rust workspace by adding a hard-blocking semver enforcement gate (release-bump-guard) that ensures release-plz cannot publish a breaking API change under a non-breaking version bump, protecting downstream consumers relying on caret version requirements.

Inherent Risks

  • The CI pipeline executes arbitrary compiled code (build.rs/proc-macros) from pull requests, an inherent risk of any Rust CI system.
  • Trust decisions are partly delegated to third-party GitHub Actions and crates.io infrastructure outside this repository's control.
  • The enforcement logic is a single shell script whose correctness the entire release-integrity guarantee depends on.

Objectives

Risk: Accept report-mode non-blocking risk on ordinary PRs while treating release branches as zero-tolerance for under-bumps.
Business: Prevent downstream consumers of vti-common and vta-sdk from receiving breaking changes disguised as compatible releases.
Security: Ensure only genuine release-plz-originated branches receive enforce-mode treatment.; Prevent untrusted PR code from gaining elevated CI token permissions during build steps.
Financial: Avoid costly emergency patch releases and consumer support burden caused by broken automatic dependency upgrades.
Compliance: Maintain semantic versioning (SemVer) compliance for all published crates to satisfy consumer contractual/API stability expectations.
Functional: Automatically detect and block release-plz version proposals that under-bump relative to real API deltas.
Operational: Keep CI runtime for semver checks within acceptable bounds despite doubling build cost (rustdoc JSON x2).

Business Impact Analysis (2)

BIA-1: Automated Crate Release Publishing (Critical)

release-plz proposes and publishes new crate versions to crates.io based on detected changes, with release-bump-guard acting as the final CI gate before publication proceeds.

MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours

  • Stakeholders: Downstream SDK Consumers / OpenVTC Maintainers / Release Engineering Team
  • Dependencies: GitHub Actions Runners / cargo-semver-checks / crates.io Registry / release-plz Automation
  • Disruptions: Release-bump-guard silently fails open due to SEMVER_MODE misconfiguration. / Malicious PR bypasses branch-name gating and forces or evades enforcement. / CI resource exhaustion delays or blocks release publishing.
  • Impacts: Downstream consumers receive breaking changes as patch/minor releases causing compile failures. / Reputational damage to OpenVTC's SemVer reliability guarantee. / Emergency yank-and-republish cycles consuming engineering time.

BIA-2: CI Pipeline Integrity for Untrusted Pull Requests (High)

Every pull request, including from external forks, triggers checkout and compilation of PR code within shared GitHub Actions runners, requiring isolation from secrets and elevated tokens.

MTD: 00 days 04:00 hours | RTO: 00 days 02:00 hours | RPO: 00 days 00:00 hours

  • Stakeholders: OpenVTC Maintainers / Open-Source Contributors / Security Team
  • Dependencies: GitHub Actions / actions/checkout / dtolnay/rust-toolchain / taiki-e/install-action
  • Disruptions: Malicious build.rs/proc-macro executes with CI secrets exposed. / Compromised third-party action tag substitutes malicious code.
  • Impacts: Exfiltration of GITHUB_TOKEN or publishing credentials. / Supply-chain compromise of published crates. / Loss of consumer trust in package integrity.

Technical Scope

Roles (3): RO-1 External Contributor · RO-2 Maintainer / Reviewer · RO-3 release-plz Automation Identity

Actors (4): AC-1 Fork/External Contributor · AC-2 Repository Maintainer · AC-3 release-plz Bot Process · AC-4 GitHub Actions Runner

Entry Points (3): EP-001 API-Break Report Job Trigger · EP-002 Release Bump Guard Job Trigger · EP-003 semver-report.sh Shell Invocation

Threat Actors (3): TA-1 Malicious External Contributor · TA-2 Supply-Chain Attacker · TA-3 Careless/Insider Contributor

Infrastructure (1): IF-1 GitHub-Hosted ubuntu-latest Runner

Trust Boundaries (3): TB-1 GitHub Actions Runner Boundary · TB-2 crates.io Publishing Boundary · TB-3 Repository Branch Protection Boundary

External Entities (3): EE-1 External PR Contributor / Fork · EE-2 release-plz Bot · EE-3 GitHub Actions Marketplace

System Components (5): SC-1 GitHub Actions CI Workflow (ci.yml) · SC-2 semver-report.sh Script · SC-3 cargo-semver-checks Tool · SC-4 crates.io Registry · SC-5 release-plz Automation

Resources And Assets (3): RA-1 GITHUB_TOKEN and CI Secrets · RA-2 Published Crate Version Manifests (Cargo.toml) · RA-3 Baseline Crate API Surface (rustdoc JSON)

Technologies And Dependencies (4): TD-1 cargo-semver-checks · TD-2 actions/checkout · TD-3 dtolnay/rust-toolchain · TD-4 taiki-e/install-action

Use Cases (2)

  • Ordinary Pull Request API-Break Reporting: A contributor opens a normal pull request; CI runs cargo-semver-checks in report mode to inform reviewers of any API-compatibility break so the release compatibility field can be adjusted before merge
  • Release-plz Branch Version Bump Enforcement: release-plz opens a release-plz-* branch carrying proposed version bumps; CI runs cargo-semver-checks in enforce mode to hard-block the release if the proposed version bump does not cover the real API

📋 Risk Registry (8)

ID Title Severity Residual Priority Effort
RISK-001 Release-integrity gate can be bypassed or falsely triggered via spoofable branch-name matching High Medium Immediate Low
RISK-002 Untrusted pull request code executes with CI runner privileges during semver-check builds High High Immediate Medium
RISK-003 Unpinned third-party GitHub Actions expose the release pipeline to supply-chain substitution High Medium Short-Term Low
RISK-004 Fail-open risk if SEMVER_MODE is unset on the enforcement job Medium Low Short-Term Low
RISK-005 Hard-coded crate exclusions create a permanent blind spot in breaking-change detection Medium Low Medium-Term Medium
RISK-006 Lack of durable audit trail for report-mode API-break warnings enables repudiation Low Low Medium-Term Low
RISK-007 CI compute exhaustion from doubled rustdoc JSON builds on every pull request Medium Low Medium-Term Low
RISK-008 Baseline crate resolution trusts crates.io without independent integrity verification Medium Low Long-Term Medium

⚔️ Attack Scenarios (2)

SC-1: GitHub Actions CI Workflow

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Malicious External Contributor<br><i>Bypass release-integrity controls or exfiltrate CI secrets</i>" }
    TA2@{ shape: rect, label: "TA-2: Supply-Chain Attacker<br><i>Compromise upstream action or registry credential</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S1@{ shape: rect, label: "STRIDE-1: Untrusted head_ref Branch-Name Matching Bypasses Enforcement<br><i>High / Likely</i>" }
    S2@{ shape: rect, label: "STRIDE-2: PR Code Execution via Untrusted checkout<br><i>High / Likely</i>" }
    S3@{ shape: rect, label: "STRIDE-3: Unpinned Third-Party Actions Supply-Chain Substitution<br><i>High / Possible</i>" }
    S8@{ shape: rect, label: "STRIDE-8: Missing permissions Block Grants Default Token Scope<br><i>Medium / Possible</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC151@{ shape: rect, label: "CAPEC-151: Identity Spoofing" }
    CAPEC242@{ shape: rect, label: "CAPEC-242: Code Injection" }
    CAPEC538@{ shape: rect, label: "CAPEC-538: Open-Source Supply Chain Insertion" }
    CAPEC233@{ shape: rect, label: "CAPEC-233: Privilege Escalation" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    CWE290@{ shape: rect, label: "CWE-290: Authentication Bypass by Spoofing" }
    CWE829@{ shape: rect, label: "CWE-829: Inclusion of Functionality from Untrusted Control Sphere" }
    CWE1357@{ shape: rect, label: "CWE-1357: Reliance on Insufficiently Trustworthy Component" }
    CWE732@{ shape: rect, label: "CWE-732: Incorrect Permission Assignment" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC1n@{ shape: rect, label: "SC-1: GitHub Actions CI Workflow" }
  end
  TA1 --> S1
  TA1 --> S2
  TA2 --> S3
  TA1 --> S8
  S1 --> CAPEC151
  S2 --> CAPEC242
  S3 --> CAPEC538
  S8 --> CAPEC233
  CAPEC151 --> CWE290
  CAPEC242 --> CWE829
  CAPEC538 --> CWE1357
  CAPEC233 --> CWE732
  CWE290 --> SC1n
  CWE829 --> SC1n
  CWE1357 --> SC1n
  CWE732 --> SC1n
  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:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FF0000,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
  linkStyle 8 stroke:#FF0000,stroke-width:2px
  linkStyle 9 stroke:#FF0000,stroke-width:2px
  linkStyle 10 stroke:#FF0000,stroke-width:2px
  linkStyle 11 stroke:#FFA500,stroke-width:2px
  linkStyle 12 stroke:#FF0000,stroke-width:2px
  linkStyle 13 stroke:#FF0000,stroke-width:2px
  linkStyle 14 stroke:#FF0000,stroke-width:2px
  linkStyle 15 stroke:#FFA500,stroke-width:2px
Loading

SC-2: semver-report.sh Script

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA3@{ shape: rect, label: "TA-3: Careless/Insider Contributor<br><i>Unintentionally ship a breaking change</i>" }
    TA2b@{ shape: rect, label: "TA-2: Supply-Chain Attacker<br><i>Tamper baseline crate resolution</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S4@{ shape: rect, label: "STRIDE-4: SEMVER_MODE Silent Fallback<br><i>Medium / Possible</i>" }
    S5@{ shape: rect, label: "STRIDE-5: Crate Exclusion Weakens Coverage<br><i>Medium / Possible</i>" }
    S6@{ shape: rect, label: "STRIDE-6: Report-Mode Repudiation of Breaks<br><i>Low / Likely</i>" }
    S10@{ shape: rect, label: "STRIDE-10: Untrusted Baseline Resolution<br><i>Medium / Possible</i>" }
    S12@{ shape: rect, label: "STRIDE-12: Log Injection via Crate Names<br><i>Low / Possible</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC21@{ shape: rect, label: "CAPEC-21: Exploitation of Trusted Identifiers" }
    CAPEC176@{ shape: rect, label: "CAPEC-176: Configuration/Environment Manipulation" }
    CAPEC593@{ shape: rect, label: "CAPEC-593: Session Hijacking (Log Trust Abuse)" }
    CAPEC538b@{ shape: rect, label: "CAPEC-538: Open-Source Supply Chain Insertion" }
    CAPEC93@{ shape: rect, label: "CAPEC-93: Log Injection" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    CWE636@{ shape: rect, label: "CWE-636: Not Failing Securely" }
    CWE693@{ shape: rect, label: "CWE-693: Protection Mechanism Failure" }
    CWE778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
    CWE345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
    CWE117@{ shape: rect, label: "CWE-117: Improper Output Neutralization for Logs" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC2n@{ shape: rect, label: "SC-2: semver-report.sh Script" }
  end
  TA3 --> S4
  TA3 --> S5
  TA3 --> S6
  TA2b --> S10
  TA3 --> S12
  S4 --> CAPEC21
  S5 --> CAPEC176
  S6 --> CAPEC593
  S10 --> CAPEC538b
  S12 --> CAPEC93
  CAPEC21 --> CWE636
  CAPEC176 --> CWE693
  CAPEC593 --> CWE778
  CAPEC538b --> CWE345
  CAPEC93 --> CWE117
  CWE636 --> SC2n
  CWE693 --> SC2n
  CWE778 --> SC2n
  CWE345 --> SC2n
  CWE117 --> SC2n
  linkStyle 0 stroke:#FFA500,stroke-width:2px
  linkStyle 1 stroke:#FFA500,stroke-width:2px
  linkStyle 2 stroke:#00FF00,stroke-width:2px
  linkStyle 3 stroke:#FFA500,stroke-width:2px
  linkStyle 4 stroke:#00FF00,stroke-width:2px
  linkStyle 5 stroke:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#FFA500,stroke-width:2px
  linkStyle 7 stroke:#00FF00,stroke-width:2px
  linkStyle 8 stroke:#FFA500,stroke-width:2px
  linkStyle 9 stroke:#00FF00,stroke-width:2px
  linkStyle 10 stroke:#FFA500,stroke-width:2px
  linkStyle 11 stroke:#FFA500,stroke-width:2px
  linkStyle 12 stroke:#00FF00,stroke-width:2px
  linkStyle 13 stroke:#FFA500,stroke-width:2px
  linkStyle 14 stroke:#00FF00,stroke-width:2px
  linkStyle 15 stroke:#FFA500,stroke-width:2px
  linkStyle 16 stroke:#FFA500,stroke-width:2px
  linkStyle 17 stroke:#00FF00,stroke-width:2px
  linkStyle 18 stroke:#FFA500,stroke-width:2px
  linkStyle 19 stroke:#00FF00,stroke-width:2px
Loading

📊 Risk Summary

Total Threats: 12

By Severity: Low: 4 · High: 3 · Medium: 5

By Category: Spoofing: 1 · Tampering: 9 · Elevation of Privilege: 4 · Information Disclosure: 4 · Denial of Service: 3 · Repudiation: 3

🎯 Attack Surface

Kill Chain 1: An attacker with only the ability to open a public pull request (TA-1) can name their fork branch with the 'release-plz-' prefix (STRIDE-1, CWE-290) to force the hard-blocking release-bump-guard job (EP-002) to evaluate their PR under enforce semantics, or conversely evade genuine enforcement by controlling manifest version fields interpreted by scripts/semver-report.sh as authoritative release intent — this branch-name spoofing is the root that several other threats chain from. Kill Chain 2: Once release-bump-guard (or the always-on report job, EP-001) checks out PR content via actions/checkout@v7, any build.rs or proc-macro code in the attacker's crate changes executes during cargo-semver-checks' rustdoc JSON generation (STRIDE-2, CWE-829); combined with the missing least-privilege permissions block (STRIDE-8, CWE-732) this yields potential GITHUB_TOKEN exfiltration, escalating a CI-configuration weakness into a credential-theft and repository-integrity incident. Kill Chain 3: Independently, the workflow's reliance on mutable action tags (actions/checkout@v7, dtolnay/rust-toolchain@stable, taiki-e/install-action@cargo-semver-checks) creates a supply-chain substitution vector (STRIDE-3, CWE-1357) that, if realized, would compromise every future CI run including the very release-bump-guard job meant to protect release integrity — an attacker controlling this chain inherits all downstream trust the enforcement gate was designed to provide. Kill Chain 4: Finally, even without any direct compromise, the enforcement logic's own design gaps — SEMVER_MODE silent fallback to report on unset input (STRIDE-4), permanent crate exclusions (STRIDE-5), and untrusted crates.io baseline resolution (STRIDE-10) — can each independently or in combination allow a genuinely breaking change to be reported as compatible, achieving the attacker's or careless contributor's ultimate goal (shipping a breaking change under a non-breaking version) without needing any of the more active code-execution or spoofing techniques above.

🛡️ Risk Mitigation Strategy

Priority 1 (Immediate): Close the identity-verification gap in release-bump-guard by validating that the triggering actor is genuinely the release-plz automation (not merely a branch-name match) and by restricting who can create branches matching 'release-plz-*' via repository rulesets — this single control eliminates the root cause underlying STRIDE-1 and reduces the exploitability of several chained threats. Priority 2 (Immediate): Apply least-privilege permissions: blocks to every job that checks out untrusted PR content, and evaluate sandboxing or network-egress restrictions for the cargo-semver-checks build step, directly addressing the high-severity code-execution and token-exposure risks in STRIDE-2 and STRIDE-8. Priority 3 (Short-Term): Pin all third-party GitHub Actions to immutable commit SHAs and adopt an organizational allow-list, closing the supply-chain substitution exposure identified in STRIDE-3 before it can be chained with the code-execution vector above. Priority 4 (Short-Term): Harden scripts/semver-report.sh's own enforcement logic by asserting MODE resolves to 'enforce' explicitly on the release-bump-guard job (STRIDE-4) and by persisting semver-check results as durable, required status artifacts rather than transient log annotations (STRIDE-6), improving both fail-safe behavior and non-repudiation. Priority 5 (Medium-Term): Address the remaining design-level coverage and integrity gaps — publish the crate exclusion list at runtime and schedule periodic full-coverage checks (STRIDE-5), add concurrency controls to bound CI resource consumption (STRIDE-7), and cross-validate baseline crate checksums against Cargo.lock pins (STRIDE-10) — to fully align the release-bump-guard's actual guarantees with its stated zero-tolerance intent.


Generated by Agentic Sec — Threat Model & Affect Analysis Agent

📊 Summary & findings
✅ Confirmed ⚠️ Must-Review-By-Human
4 0

Confirmed (4)

  • 🟡 CI job compiles untrusted PR code (build.rs/proc-macros) without least-privilege permissions or sandboxing (triaged HIGH→MEDIUM)
  • 🟡 Branch-name string match used as sole trust boundary for release-bump-guard enforcement (triaged HIGH→MEDIUM)
  • 🟡 Github Actions Mutable Action Tag (33 occurrences)
  • 🟡 Fail-open default to non-enforcing 'report' mode when SEMVER_MODE is unset/blank on the enforcement job (triaged LOW→MEDIUM)

stormer78 added a commit that referenced this pull request Sep 6, 2026
… needs (#1259)

The guard shipped in #1256 without the "Install system dependencies" step the
report job beside it has, so its first real run — against Release PR #1232, the
under-bump it was built to catch — failed on:

    error: failed to run custom build command for `libdbus-sys v0.2.7`
      pkg_config failed
    error: failed to build rustdoc for crate vta-cli-common v0.12.4

`vta-cli-common` pulls `libdbus-sys`, whose build script shells out to
pkg-config. Without the dbus headers rustdoc cannot build, and semver-report.sh
reads a failed baseline build as "a PUBLISHED CRATE DOES NOT BUILD" — loud on
purpose, because that is a real production defect when it is real. A missing
build dependency on the runner produces the identical message.

So the guard was red, for a reason that had nothing to do with version bumps,
wearing the wording of a much more serious finding. That is the same conflation
this job family has now had three times, and the reason to be exact about it:
the guard's whole purpose is to be believed when it goes red.

The apt logic moves to scripts/install-semver-deps.sh and both jobs call it. A
second copy in the second job would have been the shorter diff and the wrong
one — two lists of the same thing agree until someone edits one, which is
precisely the defect #1252 was written to fix.

Verified: both jobs now resolve to identical step lists.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78 added a commit that referenced this pull request Sep 6, 2026
Both enums are designed to grow — `Capability` gains an entry whenever the agent
gains a power worth gating separately, and `AuditEvent`'s own doc comment says
variants arrive alongside the features that emit them. Neither was
`#[non_exhaustive]`, so every one of those additions was a breaking change for
anyone matching on them.

That is not hypothetical. `MemoryRead`, `MemoryWrite`, `RoomPresent`, `RoomOpen`
and `AuditEvent::RoomOperation` went out in vti-common 0.16.2 — a PATCH release —
so a downstream exhaustive `match` stopped compiling on a routine `cargo update`,
with the caret requirement picking it up automatically.

The cost inside this workspace is zero: nothing here matches exhaustively on
either type. Every reference constructs a variant as a value, and the only
`match self` sits in `vti-common` itself, where the attribute has no effect.
Checked before writing it, not after.

Downstream code now needs a `_ =>` arm, and that is the point rather than the
price. A capability a consumer has never heard of is precisely the one it must
not silently treat as granted, and an audit event it cannot name still has to be
recorded; a wildcard arm forces both decisions to be written down instead of
being decided by a compile error at the wrong moment.

Deliberately NOT applied to the sixteen `vta-sdk` wire structs that broke the
same way when they gained `ext`. `#[non_exhaustive]` on a struct removes literal
construction from outside the crate entirely — functional update with
`..Default::default()` included, which is the part people assume still works — so
all sixteen would need constructors or builders. That is a redesign of the public
SDK surface, not cleanup, and the safety half is now covered anyway: a new field
forces a breaking bump and #1256's guard makes that stick. Worth doing
deliberately, in its own change.

Breaking for external consumers, so this needs the minor slot (0.17.0) rather
than a patch. The guard added in #1256 will now say so if the release proposes
otherwise — which makes this its first live exercise of the failure path.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78 added a commit that referenced this pull request Sep 6, 2026
* feat(vti-common)!: make Capability and AuditEvent non-exhaustive

Both enums are designed to grow — `Capability` gains an entry whenever the agent
gains a power worth gating separately, and `AuditEvent`'s own doc comment says
variants arrive alongside the features that emit them. Neither was
`#[non_exhaustive]`, so every one of those additions was a breaking change for
anyone matching on them.

That is not hypothetical. `MemoryRead`, `MemoryWrite`, `RoomPresent`, `RoomOpen`
and `AuditEvent::RoomOperation` went out in vti-common 0.16.2 — a PATCH release —
so a downstream exhaustive `match` stopped compiling on a routine `cargo update`,
with the caret requirement picking it up automatically.

The cost inside this workspace is zero: nothing here matches exhaustively on
either type. Every reference constructs a variant as a value, and the only
`match self` sits in `vti-common` itself, where the attribute has no effect.
Checked before writing it, not after.

Downstream code now needs a `_ =>` arm, and that is the point rather than the
price. A capability a consumer has never heard of is precisely the one it must
not silently treat as granted, and an audit event it cannot name still has to be
recorded; a wildcard arm forces both decisions to be written down instead of
being decided by a compile error at the wrong moment.

Deliberately NOT applied to the sixteen `vta-sdk` wire structs that broke the
same way when they gained `ext`. `#[non_exhaustive]` on a struct removes literal
construction from outside the crate entirely — functional update with
`..Default::default()` included, which is the part people assume still works — so
all sixteen would need constructors or builders. That is a redesign of the public
SDK surface, not cleanup, and the safety half is now covered anyway: a new field
forces a breaking bump and #1256's guard makes that stick. Worth doing
deliberately, in its own change.

Breaking for external consumers, so this needs the minor slot (0.17.0) rather
than a patch. The guard added in #1256 will now say so if the release proposes
otherwise — which makes this its first live exercise of the failure path.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

* docs(releasing): say what actually counts as a breaking change

Both RELEASING.md and CLAUDE.md tell an author to put `!` on "a breaking
change" and then leave the term undefined, as though it were self-evident. It is
not. The cases that get missed are the ones that add rather than remove, and
they do not feel like breaks while you are writing them.

Five went out unmarked here between 2026-08-29 and 2026-09-06:

  #1234  Capability::{MemoryRead, MemoryWrite}
  #1247  Capability::RoomPresent
  #1250  Capability::RoomOpen
  #1244  AuditEvent::RoomOperation
  #1231  sixteen vta-sdk wire structs gained `ext` — typed `fix:`, which
         derives the smallest bump there is

Neither enum was `#[non_exhaustive]`, so each variant broke every downstream
exhaustive `match`; the struct fields broke every literal. release-plz reads the
bump off the type and the `!`, so all of it shipped as patches: vti-common
0.16.2 and vta-sdk 0.32.4, which a caret requirement picks up on a routine
`cargo update`.

Three of those five are mine, which is the reason to write this down rather than
treat it as something careless people do.

So RELEASING.md now lists the additive cases explicitly, and says the better
answer is usually not the marker at all: a type designed to grow should be
`#[non_exhaustive]` once (#1262), rather than depending on every future author
remembering. CLAUDE.md gets the short form beside the existing title rule.

Both point at the mechanical check, because neither note removes the need for
one: `release bump is large enough` compares the versions a Release PR actually
proposes against cargo-semver-checks and blocks when the bump is too small. It
does not depend on anyone noticing.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>

---------

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78 added a commit that referenced this pull request Sep 6, 2026
Sixteen public request bodies gained an `ext` member in #1231 — under a `fix:`
type, which derives the smallest bump there is — and every consumer building one
with a struct literal stopped compiling. `vta-sdk` 0.32.4 shipped that as a patch
release, which a caret requirement takes on a routine `cargo update`.

`ext` is the framework's extension member (SPEC §4.5.1); these bodies gain
members whenever the schema revises. So the fix is not to remember the `!` next
time, it is for the addition to stop being breaking: fourteen of them are now
`#[non_exhaustive]` with a `new()` taking the members the schema requires. The
optional members stay public — set them on the returned value.

Deliberately NOT applied to `AclEntry` or `AppStateWrite`, which the same report
flagged. `AclEntry` has 68 construction sites, and its own doc comments record
that `None` and `Some([])` on `allowed_keys` are OPPOSITE grants: absent means
every key the entry's scopes reach, present-but-empty means no keys at all. A
generated constructor defaulting that to `None`, migrated mechanically across 68
sites, is precisely how the narrowest grant becomes the widest — the same class
of mistake CLAUDE.md already attributes to #746, #769 and #770 on the adjacent
`allowed_contexts` axis. That one wants per-site review, not a script, and it is
better done on its own.

The compiler was a better census than grep: I estimated 19 external construction
sites and it found 21, across five files including integration tests, which count
as outside the defining crate for this purpose.

Two of my own automation passes needed correcting on the way, both worth naming
because the second was nearly silent: the first brace matcher mis-parsed `//`
comments sitting inside the literals, and the second dropped the comment attached
to `authorization_context: None` while filtering out `None`-valued fields. That
comment records that `authorizationContext` is a member the published schema does
not define, so a producer cannot send it through the validated transport at all.
It is restored against the constructor default.

Breaking, so this wants the minor slot on vta-sdk. #1256's guard will say so if
the release proposes otherwise.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
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