André Ataíde
July 6, 2026

Wardex v2.2.2: CI/CD Hardening After Cordyceps

The Cordyceps vulnerability class does not affect Wardex. The internal pipeline review, however, identified three gaps that required correction.


Context

Wardex is a solo research project. All features, hardening, and fixes derive from ongoing threat intelligence analysis and observation of patterns in third-party ecosystems. There are no external production deployments, and the code is hardened against the same patterns the tool detects in other codebases.

The Cordyceps class, documented by Novee Security, describes a systemic vulnerability pattern in GitHub Actions pipelines: workflows using privileged triggers (pull_request_target, issue_comment, workflow_run) combined with user-controlled data interpolation (branch names in shell commands, PR titles in run: blocks, comment bodies in actions/github-script).

The Novee publication prompted a full internal review of github.com/had-nu/wardex against the documented attack chains. The result: Wardex is not vulnerable to the Cordyceps pattern. The investigation, however, revealed three residual risks — none of them Cordyceps, but all relevant to supply chain integrity and CI/CD pipeline hardening.


Cordyceps Analysis

Every workflow in the repository was reviewed against the four patterns documented by Novee. The verdict was negative on three grounds:

  1. Correct trigger model. ci.yml uses pull_request — sandboxed by design, no secret access, no write permissions. Privileged triggers do not exist in any workflow in this repository.
  2. No dangerous interpolation. None of the documented patterns — branch names in shell, PR titles in run: blocks, comment bodies in actions/github-script, or artifact outputs in elevated contexts — are present. The ${{ github.ref_name }} usage in docker.yml is confined to a Docker build-arg via a parameterised action.
  3. Complete SHA pinning. All external actions are pinned to commit hashes, eliminating the tag-mutation vector.

Gaps Identified

A negative Cordyceps verdict does not equate to a hardened pipeline. The review identified three gaps:

CI-1: Syft installed via curl | bash — no integrity verification

The release.yml pipeline installed Syft with:

curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s

No version pinning, no hash verification. A compromise of the anchore/syft repository or an unreviewed change to the script on main would result in arbitrary code execution on a runner with GHCR write access and the GITHUB_TOKEN. Trivy and Cosign were already pinned. Syft was the outlier.

CI-2: Branch images published unsigned

docker.yml published branch-triggered images to the same registry as release images (ghcr.io/had-nu/wardex), but without cosign signing — the signing step was gated on if: startsWith(github.ref, 'refs/tags/').

Any contributor with write access to the dev branch could publish an unsigned image tagged with a commit SHA, carrying no integrity attestation, no SBOM, and no SLSA provenance. Wardex itself implements wardex art14 verify and wardex accept verify to detect tampered artifacts — its own Docker image should be subject to the same level of verification.

CI-3: Path traversal via symlink bypass (SafePath)

The action.yml passes inputs to the Wardex container without YAML-layer sanitisation. The relevant attack vector is path traversal: if the CLI does not validate paths, a Marketplace Action user can read files outside the workspace with --evidence ../../.env, write outputs to arbitrary locations with --out-file ../../.github/workflows/evil.yml, or expose runner secrets via --config /proc/self/environ on Linux runners.

The existing SafePath function in pkg/utils/path.go used filepath.Clean plus a prefix check, but did not resolve symlinks. An attacker who controls a file in a writable base directory can create a symlink pointing outside the workspace, and SafePath would accept it as valid.


Fixes Applied

CI-1: SHA-256 verification for Syft download

The curl | bash invocation was replaced with a direct binary download and SHA-256 checksum verification:

# release.yml — after
- name: Install syft
  env:
    SYFT_VERSION: "1.19.0"
    SYFT_SHA256: "1dec148ea36aef68a866e35528974b5dbc106ba0b545f1a262ad977d48294637"
  run: |
    curl -sSfL \
      "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
      -o syft.tar.gz
    echo "${SYFT_SHA256}  syft.tar.gz" | sha256sum --check --strict
    tar -xzf syft.tar.gz syft
    install -m 0755 syft /usr/local/bin
    rm syft.tar.gz syft

sha256sum was chosen over anchore/sbom-action/download-syft deliberately: the verification step is explicit and visible in every PR diff, avoiding the opacity of a third-party action.

CI-2: dev- namespace and sign all builds

Two changes to docker.yml:

  1. Branch-triggered images now use a dev- namespace prefix, separating them visually from release images.
  2. The cosign signing step no longer gates on tags — every build that passes the Trivy gate is signed immediately.
# docker.yml — metadata
- name: Extract metadata
  id: meta
  uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
  with:
    images: ghcr.io/${{ github.repository }}
    tags: |
      type=semver,pattern={{version}}
      type=semver,pattern={{major}}.{{minor}}
      type=ref,event=branch,prefix=dev-
      type=sha,format=long,prefix=sha-

# docker.yml — signing without tag gate
- name: Sign image with cosign keyless
  run: |
    cosign sign ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} --yes

CI-3: pathguard — symlink-aware path validation

A new pkg/cli/pathguard.go package replaces utils.SafePath with two functions: ValidateInputPath (reads) and ValidateOutputPath (writes). The key difference: filepath.EvalSymlinks runs before the containment check, closing the symlink bypass.

All 33 call sites of SafePath across 19+ files were migrated. Two additional bugs were fixed during the migration:

The test suite includes 33 tests covering traversal, symlink escape, null bytes, UTF-8 validation, shell injection patterns, and pseudo-filesystem access (/proc/, /sys/, /dev/):

$ go test -race ./pkg/cli/...
ok      github.com/had-nu/wardex/v2/pkg/cli       0.026s    coverage: 90.9%

New Commands (v2.2.2)

Eleven new commands were added in this release:

CommandDescription
wardex trust listList all keys in the trust store with status, role, and metadata. Table and JSON output.
wardex trust show <id>Display detailed information for a specific trust key, including revocation status and signatures.
wardex trust verifyVerify root signature validity and key status across the entire trust store.
wardex config showDisplay configuration metadata: risk appetite, gate mode, CRA Art.14 status, state store, and SHA-256 hash.
wardex auth statusShow trust store integrity with admin, active, and revoked key counts.
wardex auth verify --actorVerify actor permissions against the trust store, listing all permitted operations.
wardex contract verifyCompute SHA-256 hash of a contract file and optionally verify against an expected hash. Shows file size and modification time.
wardex assets inventoryDisplay ICT asset inventory with criticality, internet exposure, network zone, and owner. Table, JSON, and CSV output.
wardex hmac signCompute HMAC-SHA256 signature for file integrity verification using a secret from the environment.
wardex convert kevConvert the CISA KEV catalogue to Wardex YAML format for use with evaluate.
wardex chain sealCreate a SHA-256 chain seal of all files in a directory for integrity verification.

Provenance Attestation — Preview for v2.3.0

The v2.2.2 release ships with a signed provenance manifest as a technology preview. The manifest contains BLAKE3 hashes of all 113 source files, signed with an Ed25519 key. This attestation was generated using the v2.3.0 provenance sub-module, currently under development.

The rationale for including provenance in a CI/CD hardening release is twofold:

  1. Dogfooding — validating the provenance tooling against a real release before it ships as a built-in feature. The manifest generation uncovered a filepath.Match bug (hand-rolled globbing did not handle *.go correctly) that was fixed before the v2.3.0 branch freeze.
  2. Release integrity — the same release that closes CI-1, CI-2, and CI-3 now carries its own integrity attestation. Anyone verifying the source tree can confirm that the code matches the official release, independently of GitHub's own checksums.

Starting in v2.3.0, provenance attestation will be built into the Wardex CLI itself — no separate binary, no separate workflow. The wardex provenance seal and wardex provenance verify commands will generate and validate signed manifests for any directory, with optional Bitcoin anchoring via OpenTimestamps and Ethereum/Polygon anchoring via a smart contract.

Signing public key (ed25519):
ed25519:HsD9e6BB2LlaeKODGqgWUZoflDgdUH1HWTdyWA7dGqE=

Root hash (BLAKE3, 113 files):
sha256:6f972edf99f5457f8fb13668c529f4343dab7a76d20b67ea746ebdf54d910fee

# Verify source tree integrity (requires the v2.3.0 binary)
immutable-provenance verify \
  --manifest provenance-manifest-v2.2.2-signed.yaml \
  --dir /path/to/wardex-v2.2.2

Architectural Decisions


Lesson

The exercise identified a policy-practice gap in the project's own release pipeline. The declared policy is SHA pinning for all external dependencies. The actual practice installed Syft via curl | bash from the main branch of anchore/syft — no hash, no verification.

Wardex is designed to detect precisely this delta in corporate environments. This release closes the delta for the project itself.


Upgrade

go install github.com/had-nu/wardex/v2@latest

Checksums are available in wardex_2.2.2_checksums.txt from the GitHub Release.