Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,13 @@ jobs:
name: Create GitHub Release
needs: [build-release]
runs-on: ubuntu-latest
# Least privilege, scoped to this job only:
# contents: write -> create the GitHub Release and upload assets
# id-token: write -> mint the GitHub OIDC token cosign exchanges for a
# short-lived Sigstore (Fulcio) signing certificate (#591)
permissions:
contents: write
id-token: write

steps:
- uses: actions/checkout@v6
Expand All @@ -156,6 +161,36 @@ jobs:
sha256sum * > checksums-sha256.txt
cat checksums-sha256.txt

# Keyless signing (#591). Ordering matters: sign BEFORE creating the
# release. If signing fails the job stops here and no release is
# published, so we never ship a release that lacks its signature.
# One signature over the checksums file covers every archive, because
# install.sh / `cora upgrade` verify each archive against that file.
# sigstore/cosign-installer v3.10.1, pinned by commit SHA.
- name: Install cosign
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5
with:
cosign-release: 'v2.6.5'

- name: Sign checksums with cosign (keyless)
working-directory: release-artifacts
run: |
cosign sign-blob --yes \
--bundle checksums-sha256.txt.sigstore.json \
checksums-sha256.txt
test -s checksums-sha256.txt.sigstore.json

# Fail the job (before publishing) if the bundle does not verify against
# this exact workflow identity.
- name: Verify the fresh signature
working-directory: release-artifacts
run: |
cosign verify-blob \
--bundle checksums-sha256.txt.sigstore.json \
--certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@${GITHUB_REF}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums-sha256.txt

- name: Extract version from tag
id: version
run: |
Expand Down
1 change: 1 addition & 0 deletions AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ After release completes, verify:
- [ ] GitHub Release page shows vX.Y.Z with correct changelog
- [ ] 4 platform binaries attached to release
- [ ] SHA256 checksums file included
- [ ] `checksums-sha256.txt.sigstore.json` asset exists and verifies (`cosign verify-blob` command in `docs/installation.md#verify-release-signatures`); `CORA_REQUIRE_SIGNATURE=1 CORA_VERSION=vX.Y.Z sh install.sh` succeeds
- [ ] `crates.io` shows new version: `cargo search cora-code`
- [ ] `codecora.dev` reflects new docs
- [ ] Marketplace action still works (test on a test PR)
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- **Release checksums are signed with cosign keyless and verified by `install.sh` and `cora upgrade` (#591).** `release.yml` now signs `checksums-sha256.txt` (which covers every archive) via Sigstore/GitHub OIDC before creating the release and uploads `checksums-sha256.txt.sigstore.json`; a signing failure aborts the job so no unsigned release is published. `install.sh` and `cora upgrade` verify the bundle with `cosign verify-blob` (identity: this repo's `release.yml` on a `v*` tag, issuer `token.actions.githubusercontent.com`) when `cosign` is on `PATH`; an invalid signature aborts. Without `cosign`, or for releases without a bundle, they print a notice and fall back to checksum-only. `CORA_REQUIRE_SIGNATURE=1` makes a missing `cosign` or bundle fatal. See `docs/installation.md`.

### Fixed

- **`rules_engine.max_findings` is applied after suppression and truncation is reported (#624).** The cap (default 5) used to be applied inside each scanner, before `ignore.rules` / `cora-ignore`, so suppressed findings consumed slots and pushed real findings out, and the rest were dropped silently. Scanners now run uncapped and the cap is enforced once in post-processing on deterministic findings only (LLM issues are never capped), highest severity first. `cora scan` and `cora review` print `⚠ N more deterministic findings not shown (...)` on stderr when findings are cut, and the `cora scan` summary mentions it; stdout formats are unchanged. `max_findings: 0` or `null` now means unlimited (previously `0` hid every finding).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ cargo install --git https://github.com/codecoradev/cora-code

> Pin a version: `CORA_VERSION=v0.6.1 curl -fsSL ... | sh`

**Upgrading:** run `cora upgrade` (downloads the latest release, verifies its SHA-256 checksum, replaces the binary) or `cora upgrade --check` to just see if one is available. If you installed via `cargo install --path .`, re-run that instead.
**Upgrading:** run `cora upgrade` (downloads the latest release, verifies its SHA-256 checksum and, when `cosign` is installed, its Sigstore signature, replaces the binary) or `cora upgrade --check` to just see if one is available. If you installed via `cargo install --path .`, re-run that instead.

**Verify which `cora` you're running** — `which -a cora` will reveal stale copies from other channels:

Expand Down
9 changes: 9 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ Security-related areas of the codebase:
- `src/hook/` — Pre-commit hook integration
- `src/index/` — File access and SQLite storage

## Release Artifact Signing

Release checksums are signed with cosign keyless (Sigstore, GitHub OIDC) by the
`release.yml` workflow, and `install.sh` / `cora upgrade` verify the signature
when `cosign` is installed (`CORA_REQUIRE_SIGNATURE=1` makes it mandatory).
Manual verification, the exact `cosign verify-blob` flags, and the scope and
limits of this guarantee are documented in
[docs/installation.md](docs/installation.md#verify-release-signatures).

## Threat Model: Adversarial Source-Code Comments (ALIBI)

LLM-based reviewers are vulnerable to adversarial comments in the code under
Expand Down
49 changes: 49 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,55 @@ $ echo 'source ~/.cora/completion.zsh' >> ~/.zshrc
$ cora completion fish > ~/.config/fish/completions/cora.fish
```

## Verify Release Signatures

Every release (from the first signed release onward) publishes
`checksums-sha256.txt.sigstore.json`, a [Sigstore](https://www.sigstore.dev/)
bundle produced by the release workflow with **cosign keyless signing** (GitHub
OIDC, no long-lived key). One signature over `checksums-sha256.txt` covers every
archive, because each archive is verified against that file.

Verify manually (requires [cosign](https://docs.sigstore.dev/cosign/system_config/installation/)):

```bash
TAG=vX.Y.Z
BASE=https://github.com/codecoradev/cora-code/releases/download/$TAG
curl -fsSLO $BASE/checksums-sha256.txt
curl -fsSLO $BASE/checksums-sha256.txt.sigstore.json
cosign verify-blob \
--bundle checksums-sha256.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/codecoradev/cora-code/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums-sha256.txt
sha256sum -c --ignore-missing checksums-sha256.txt # shasum -a 256 -c on macOS
```

`install.sh` and `cora upgrade` perform the same check automatically when
`cosign` is on your `PATH`:

| Situation | Default | `CORA_REQUIRE_SIGNATURE=1` |
|-----------|---------|----------------------------|
| Bundle present, `cosign` installed, signature valid | install | install |
| Bundle present, `cosign` installed, signature invalid | **abort** | **abort** |
| Bundle present, `cosign` not installed | install, prints a "checksum-only" notice | **abort** |
| No bundle published (release predates signing) | install, prints a notice | **abort** |
| Bundle download fails (not a 404) | **abort** | **abort** |

`CORA_REQUIRE_SIGNATURE=1` also conflicts with `CORA_SKIP_CHECKSUM=1`
(`CORA_UPGRADE_SKIP_CHECKSUM` for `cora upgrade`): skipping the checksum file
would silently skip the signature, so the combination is rejected.

**What this covers:** the checksums file (and therefore the archives) was
produced by this repository's `release.yml` workflow on a `v*` tag, and is
recorded in the Sigstore transparency log. A compromised release page or CDN
cannot forge it without also compromising the workflow.

**What it does not cover:** a compromised repository or workflow itself,
releases published before signing was introduced, and installs where `cosign`
is absent and `CORA_REQUIRE_SIGNATURE` is not set (those fall back to
checksum-only, which protects against corruption but not a tampered release).
`cargo install` is verified by crates.io, not by this mechanism.

## Verify Installation

Confirm cora is installed correctly:
Expand Down
62 changes: 62 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,63 @@ get_target() {
esac
}

# Keyless cosign (Sigstore) identity that must have signed the release (#591).
COSIGN_IDENTITY_REGEXP="^https://github.com/${REPO}/\\.github/workflows/release\\.yml@refs/tags/v"
COSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com"

# Is CORA_REQUIRE_SIGNATURE on? Sets REQUIRE_SIG=1|0. Unknown values are an
# error rather than a silent "off" so a requested verification is never lost.
parse_require_signature() {
case "${CORA_REQUIRE_SIGNATURE:-}" in
""|0|false) REQUIRE_SIG=0;;
1|true) REQUIRE_SIG=1;;
*) error "CORA_REQUIRE_SIGNATURE must be 1 or 0 (got '${CORA_REQUIRE_SIGNATURE}')";;
esac
}

# Verify the cosign keyless signature over the checksums file.
# Policy (see docs/installation.md):
# bundle present + cosign present -> verify; failure is fatal (fail closed)
# cosign missing -> notice (checksum-only); fatal if REQUIRE_SIG=1
# bundle absent (HTTP 404) -> notice (older release); fatal if REQUIRE_SIG=1
# any other bundle download error -> fatal
verify_signature() {
BUNDLE_URL="${CHECKSUMS_URL}.sigstore.json"
BUNDLE_FILE="${CHECKSUM_FILE}.sigstore.json"

HTTP_CODE=$(curl -sSL --connect-timeout 10 --max-time 30 --max-filesize 1048576 \
-w '%{http_code}' "$BUNDLE_URL" -o "$BUNDLE_FILE") || HTTP_CODE="000"
case "$HTTP_CODE" in
200) ;;
404)
if [ "$REQUIRE_SIG" = "1" ]; then
error "No signature bundle published for ${VERSION} but CORA_REQUIRE_SIGNATURE=1. Refusing to install."
fi
warn "No cosign signature published for ${VERSION}; verified by SHA-256 checksum only."
return 0
;;
*) error "Failed to download signature bundle (HTTP ${HTTP_CODE}). Refusing to install (set CORA_SKIP_CHECKSUM=1 to bypass all verification, unsafe).";;
esac

if ! command -v cosign >/dev/null 2>&1; then
if [ "$REQUIRE_SIG" = "1" ]; then
error "cosign not found but CORA_REQUIRE_SIGNATURE=1. Install cosign: https://docs.sigstore.dev/cosign/system_config/installation/"
fi
warn "Signature NOT verified (checksum-only): cosign not found. Install it to verify: https://docs.sigstore.dev/cosign/system_config/installation/"
return 0
fi

info "Verifying cosign signature..."
if ! cosign verify-blob \
--bundle "$BUNDLE_FILE" \
--certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP" \
--certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" \
"$CHECKSUM_FILE" >/dev/null 2>&1; then
error "cosign signature verification FAILED for checksums of ${VERSION}. Refusing to install."
fi
info "Signature verified (cosign keyless, GitHub Actions release workflow)"
}

# Download and install
install() {
info "Detected: $OS $ARCH"
Expand All @@ -111,13 +168,18 @@ install() {
# Verify SHA256 checksum (prevents MITM / corrupted download).
# Mandatory: a missing checksums file or entry is fatal unless the user
# explicitly opts out with CORA_SKIP_CHECKSUM=1.
parse_require_signature
if [ "${CORA_SKIP_CHECKSUM:-}" = "1" ]; then
if [ "$REQUIRE_SIG" = "1" ]; then
error "CORA_SKIP_CHECKSUM=1 conflicts with CORA_REQUIRE_SIGNATURE=1 (the signature covers the checksums file). Unset one."
fi
warn "CORA_SKIP_CHECKSUM=1 set - checksum verification DISABLED. The binary is NOT verified."
else
info "Downloading checksums..."
if ! curl -fsSL --connect-timeout 10 --max-time 30 --max-filesize 1048576 "$CHECKSUMS_URL" -o "$CHECKSUM_FILE"; then
error "Failed to download checksums. Refusing to install an unverified binary (set CORA_SKIP_CHECKSUM=1 to override, unsafe)."
fi
verify_signature
info "Verifying SHA256 checksum..."
# Exact filename match (optionally prefixed with '*' or './').
EXPECTED=$(awk -v n="$ARCHIVE_NAME" '{f=$2; sub(/^\*/, "", f); sub(/^\.\//, "", f); if (f == n) {print $1; exit}}' "$CHECKSUM_FILE")
Expand Down
78 changes: 78 additions & 0 deletions scripts/test_install_signature.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/bin/sh
# Decision-matrix test for install.sh verify_signature() (#591).
# No network: uses a stub curl and a stub cosign. Usage: sh scripts/test_install_signature.sh
set -u
ROOT=$(cd "$(dirname "$0")/.." && pwd)
T=$(mktemp -d)
trap 'rm -rf "$T"' EXIT
mkdir "$T/with" "$T/without"

cat > "$T/with/curl" <<'STUB'
#!/bin/sh
out=""; w=""
while [ $# -gt 0 ]; do case "$1" in -o) out="$2"; shift;; -w) w="$2"; shift;; esac; shift; done
[ "$FAKE_CODE" = "ERR" ] && exit 28
[ "$FAKE_CODE" = "200" ] && echo bundle > "$out"
[ -n "$w" ] && printf '%s' "$FAKE_CODE"
exit 0
STUB
cat > "$T/with/cosign" <<'STUB'
#!/bin/sh
echo "$@" > "$COSIGN_ARGS"
exit "${FAKE_COSIGN_RC:-0}"
STUB
chmod +x "$T/with/curl" "$T/with/cosign"
cp "$T/with/curl" "$T/without/curl"
# install.sh without its trailing `main` call
sed '$d' "$ROOT/install.sh" > "$T/lib.sh"

FAIL=0
# check <name> <http> <cosign 1|0> <cosign rc> <REQUIRE> <expected rc> <expected output substring>
check() {
name=$1; code=$2; has=$3; crc=$4; req=$5; want=$6; sub=$7
if [ "$has" = 1 ]; then P="$T/with"; else P="$T/without"; fi
out=$( (
FAKE_CODE=$code FAKE_COSIGN_RC=$crc CORA_REQUIRE_SIGNATURE=$req COSIGN_ARGS="$T/args"
export FAKE_CODE FAKE_COSIGN_RC CORA_REQUIRE_SIGNATURE COSIGN_ARGS
PATH="$P:/usr/bin:/bin"
# shellcheck disable=SC1091
. "$T/lib.sh"
VERSION=v9.9.9
CHECKSUMS_URL=http://x/checksums-sha256.txt
CHECKSUM_FILE="$T/checksums-sha256.txt"
: > "$CHECKSUM_FILE"
parse_require_signature
verify_signature
) 2>&1 )
rc=$?
case "$out" in *"$sub"*) okout=1;; *) okout=0;; esac
if [ "$rc" = "$want" ] && [ "$okout" = 1 ]; then
echo "PASS $name"
else
echo "FAIL $name (rc=$rc want=$want) $out"
FAIL=1
fi
}

check "verified" 200 1 0 "" 0 "Signature verified"
check "cosign rejects -> fatal" 200 1 1 "" 1 "FAILED"
check "cosign missing -> notice" 200 0 0 "" 0 "NOT verified"
check "cosign missing + REQUIRE" 200 0 0 1 1 "cosign not found"
check "no bundle -> notice" 404 1 0 "" 0 "checksum only"
check "no bundle + REQUIRE" 404 1 0 1 1 "CORA_REQUIRE_SIGNATURE=1"
check "HTTP 500 -> fatal" 500 1 0 "" 1 "Failed to download signature"
check "network error -> fatal" ERR 1 0 "" 1 "Failed to download signature"
check "invalid env -> fatal" 200 1 0 yes 1 "must be 1 or 0"

# Re-run the success case so $T/args holds the verified invocation, then check flags.
check "verified (args)" 200 1 0 "" 0 "Signature verified"
if grep -q -- "--certificate-oidc-issuer https://token.actions.githubusercontent.com" "$T/args" \
&& grep -q -- 'release\\.yml@refs/tags/v' "$T/args" \
&& grep -q -- "--bundle" "$T/args"; then
echo "PASS cosign args"
else
echo "FAIL cosign args: $(cat "$T/args")"
FAIL=1
fi

exit "$FAIL"
Loading
Loading