mirror of
https://github.com/usestrix/strix.git
synced 2026-09-24 00:51:20 +00:00
Merge 1b74f806fc into 4c1f00d1ee
This commit is contained in:
commit
fa56cf343f
5 changed files with 801 additions and 10 deletions
96
.github/workflows/build-release.yml
vendored
96
.github/workflows/build-release.yml
vendored
|
|
@ -32,6 +32,10 @@ jobs:
|
|||
wheel-platform: win_amd64
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
|
@ -92,12 +96,46 @@ jobs:
|
|||
tar -C dist/release -czvf "dist/release/strix-${VERSION}-${{ matrix.target }}.tar.gz" "strix-${VERSION}-${{ matrix.target }}"
|
||||
fi
|
||||
|
||||
# Sigstore keyless provenance (GitHub OIDC). Attest the published archive
|
||||
# and wheel on the runner that built them so the installer can pin
|
||||
# usestrix/strix/.github/workflows/build-release.yml. The bundle is also
|
||||
# copied into dist/release/ so it ships as a release asset for manual /
|
||||
# offline verification (#1267).
|
||||
- name: Attest Unix archive and wheel
|
||||
id: attest-unix
|
||||
if: runner.os != 'Windows'
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
dist/release/*.tar.gz
|
||||
dist/*.whl
|
||||
|
||||
- name: Attest Windows archive and wheel
|
||||
id: attest-windows
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
dist/release/*.zip
|
||||
dist/*.whl
|
||||
|
||||
- name: Publish attestation bundle
|
||||
shell: bash
|
||||
env:
|
||||
# Only one of the attest steps runs; the other output is empty.
|
||||
BUNDLE_PATH: ${{ steps.attest-unix.outputs.bundle-path }}${{ steps.attest-windows.outputs.bundle-path }}
|
||||
run: |
|
||||
test -n "$BUNDLE_PATH"
|
||||
test -f "$BUNDLE_PATH"
|
||||
cp "$BUNDLE_PATH" "dist/release/strix-${{ matrix.target }}.intoto.jsonl"
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: strix-${{ matrix.target }}
|
||||
path: |
|
||||
dist/release/*.tar.gz
|
||||
dist/release/*.zip
|
||||
dist/release/*.intoto.jsonl
|
||||
dist/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
|
|
@ -106,6 +144,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
|
|
@ -113,9 +153,65 @@ jobs:
|
|||
path: release
|
||||
merge-multiple: true
|
||||
|
||||
# Same-origin integrity manifest for product assets (archives + wheels).
|
||||
# Provenance bundles are published alongside the release but excluded here
|
||||
# so SHA256SUMS stays a stable product-byte list the installer greps.
|
||||
- name: Generate SHA256SUMS
|
||||
working-directory: release
|
||||
shell: bash
|
||||
run: |
|
||||
: > SHA256SUMS
|
||||
while IFS= read -r -d '' f; do
|
||||
( cd "$(dirname "$f")" && sha256sum "$(basename "$f")" )
|
||||
done < <(find . -type f ! -name SHA256SUMS ! -name '*.intoto.jsonl' -print0 | sort -z) >> SHA256SUMS
|
||||
echo "=== SHA256SUMS ==="
|
||||
cat SHA256SUMS
|
||||
|
||||
- name: Attest SHA256SUMS
|
||||
id: attest-sums
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: release/SHA256SUMS
|
||||
|
||||
- name: Publish SHA256SUMS attestation bundle
|
||||
shell: bash
|
||||
env:
|
||||
BUNDLE_PATH: ${{ steps.attest-sums.outputs.bundle-path }}
|
||||
run: |
|
||||
test -n "$BUNDLE_PATH"
|
||||
test -f "$BUNDLE_PATH"
|
||||
cp "$BUNDLE_PATH" release/SHA256SUMS.intoto.jsonl
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
with:
|
||||
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
generate_release_notes: true
|
||||
# Prepended to the generated changelog. Keep in sync with
|
||||
# docs/quickstart.mdx "Verify a downloaded release".
|
||||
body: |
|
||||
## Verify this release
|
||||
|
||||
After downloading your platform archive from this release:
|
||||
|
||||
```bash
|
||||
# Example for linux-x86_64 — set ARCHIVE to the asset you downloaded.
|
||||
TARGET=linux-x86_64
|
||||
ARCHIVE=strix-VERSION-${TARGET}.tar.gz # VERSION without the leading v
|
||||
|
||||
curl -fsSLO "https://github.com/usestrix/strix/releases/download/${{ github.ref_name }}/SHA256SUMS"
|
||||
curl -fsSLO "https://github.com/usestrix/strix/releases/download/${{ github.ref_name }}/strix-${TARGET}.intoto.jsonl"
|
||||
|
||||
sha256sum -c --ignore-missing SHA256SUMS
|
||||
|
||||
gh attestation verify "${ARCHIVE}" \
|
||||
--repo usestrix/strix \
|
||||
--bundle "strix-${TARGET}.intoto.jsonl" \
|
||||
--signer-workflow usestrix/strix/.github/workflows/build-release.yml \
|
||||
--predicate-type https://slsa.dev/provenance/v1 \
|
||||
--deny-self-hosted-runners
|
||||
```
|
||||
|
||||
Full instructions (including `cosign`): https://docs.strix.ai/quickstart#verify-a-downloaded-release
|
||||
# Includes SHA256SUMS and *.intoto.jsonl provenance bundles.
|
||||
files: release/**
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ description: "Install Strix and run your first security scan"
|
|||
|
||||
- Docker (running)
|
||||
- An LLM API key from any [supported provider](/llm-providers/overview) (OpenAI, Anthropic, Google, etc.)
|
||||
- For the curl installer: [GitHub CLI](https://cli.github.com/) (`gh`) or [cosign](https://docs.sigstore.dev/cosign/system_config/installation/) — used to verify Sigstore provenance before extract
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -15,6 +16,14 @@ description: "Install Strix and run your first security scan"
|
|||
```bash
|
||||
curl -sSL https://strix.ai/install | bash
|
||||
```
|
||||
|
||||
The installer downloads the release archive, checks `SHA256SUMS`, then verifies
|
||||
Sigstore provenance with `gh` or `cosign` before extracting. To skip those checks
|
||||
(not recommended):
|
||||
|
||||
```bash
|
||||
curl -sSL https://strix.ai/install | STRIX_INSTALL_SKIP_VERIFY=1 bash
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="pipx">
|
||||
```bash
|
||||
|
|
@ -23,6 +32,44 @@ description: "Install Strix and run your first security scan"
|
|||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Verify a downloaded release
|
||||
|
||||
If you download assets from GitHub Releases yourself:
|
||||
|
||||
```bash
|
||||
# Replace VERSION and TARGET (e.g. 1.6.2 and linux-x86_64).
|
||||
VERSION=1.6.2
|
||||
TARGET=linux-x86_64
|
||||
ARCHIVE="strix-${VERSION}-${TARGET}.tar.gz"
|
||||
|
||||
curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/${ARCHIVE}"
|
||||
curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/SHA256SUMS"
|
||||
curl -fsSLO "https://github.com/usestrix/strix/releases/download/v${VERSION}/strix-${TARGET}.intoto.jsonl"
|
||||
|
||||
# 1) Integrity
|
||||
sha256sum -c --ignore-missing SHA256SUMS
|
||||
|
||||
# 2) Provenance (GitHub CLI)
|
||||
gh attestation verify "${ARCHIVE}" \
|
||||
--repo usestrix/strix \
|
||||
--bundle "strix-${TARGET}.intoto.jsonl" \
|
||||
--signer-workflow usestrix/strix/.github/workflows/build-release.yml \
|
||||
--predicate-type https://slsa.dev/provenance/v1 \
|
||||
--deny-self-hosted-runners
|
||||
```
|
||||
|
||||
With `cosign` instead of `gh`:
|
||||
|
||||
```bash
|
||||
cosign verify-blob-attestation \
|
||||
--bundle "strix-${TARGET}.intoto.jsonl" \
|
||||
--new-bundle-format \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp '^https://github.com/usestrix/strix/.github/workflows/build-release.yml' \
|
||||
--type slsaprovenance1 \
|
||||
"${ARCHIVE}"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set your LLM provider:
|
||||
|
|
|
|||
|
|
@ -85,6 +85,12 @@ fi
|
|||
|
||||
filename="$APP-${specific_version}-${target}${archive_ext}"
|
||||
url="https://github.com/$REPO/releases/download/v${specific_version}/$filename"
|
||||
sums_name="SHA256SUMS"
|
||||
sums_url="https://github.com/$REPO/releases/download/v${specific_version}/$sums_name"
|
||||
bundle_name="strix-${target}.intoto.jsonl"
|
||||
bundle_url="https://github.com/$REPO/releases/download/v${specific_version}/$bundle_name"
|
||||
SIGNER_WORKFLOW="$REPO/.github/workflows/build-release.yml"
|
||||
CERT_IDENTITY_REGEXP="^https://github.com/${REPO}/.github/workflows/build-release.yml"
|
||||
|
||||
print_message() {
|
||||
local level=$1
|
||||
|
|
@ -99,6 +105,9 @@ print_message() {
|
|||
echo -e "${color}${message}${NC}"
|
||||
}
|
||||
|
||||
# Remove other copies of strix from PATH (pipx, leftover binaries) only after
|
||||
# a verified install has been written to INSTALL_DIR. Calling this earlier
|
||||
# would leave the user with no working Strix if checksum/provenance then fail.
|
||||
check_existing_installation() {
|
||||
local found_paths=()
|
||||
while IFS= read -r -d '' path; do
|
||||
|
|
@ -129,9 +138,129 @@ check_existing_installation() {
|
|||
fi
|
||||
}
|
||||
|
||||
check_version() {
|
||||
check_existing_installation
|
||||
abort_unverified() {
|
||||
echo -e "${RED}✗ Refusing to install an unverified binary.${NC}"
|
||||
if [[ -x "$INSTALL_DIR/strix" || -x "$INSTALL_DIR/strix.exe" ]]; then
|
||||
echo -e "${MUTED}Existing Strix installation left unchanged.${NC}"
|
||||
fi
|
||||
echo -e "${RED}Re-run with: curl -sSL https://strix.ai/install | STRIX_INSTALL_SKIP_VERIFY=1 bash${NC}"
|
||||
echo -e "${MUTED}(at your own risk)${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Fail-closed checksum check against the published SHA256SUMS manifest.
|
||||
# Same-origin only (detects corruption / single-asset swap). Exact field
|
||||
# match — do not grep the filename as a regex ('.' would be wild).
|
||||
verify_checksum() {
|
||||
local file=$1
|
||||
|
||||
if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then
|
||||
echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping checksum verification (at your own risk).${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local sha_cmd=""
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha_cmd="sha256sum"
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
sha_cmd="shasum -a 256"
|
||||
else
|
||||
echo -e "${RED}✗ Neither 'sha256sum' nor 'shasum' is available; cannot verify integrity.${NC}"
|
||||
abort_unverified
|
||||
fi
|
||||
|
||||
if [ ! -s "$sums_name" ]; then
|
||||
echo -e "${RED}✗ Missing checksum manifest ${sums_name}.${NC}"
|
||||
abort_unverified
|
||||
fi
|
||||
|
||||
echo -e "${MUTED}Verifying checksum...${NC}"
|
||||
|
||||
local expected
|
||||
expected=$(awk -v file="$file" '
|
||||
$2 == file || $2 == ("*" file) { print $1; exit }
|
||||
' "$sums_name")
|
||||
if [ -z "$expected" ]; then
|
||||
echo -e "${RED}✗ No SHA256SUMS entry for ${file}.${NC}"
|
||||
abort_unverified
|
||||
fi
|
||||
|
||||
local actual
|
||||
actual=$($sha_cmd "$file" | awk '{print $1}')
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo -e "${RED}✗ Checksum mismatch for ${file}.${NC}"
|
||||
echo -e "${MUTED}Expected: ${NC}$expected"
|
||||
echo -e "${MUTED}Actual: ${NC}$actual"
|
||||
abort_unverified
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Checksum verified${NC}"
|
||||
}
|
||||
|
||||
gh_can_verify_attestation() {
|
||||
command -v gh >/dev/null 2>&1 && gh attestation verify --help >/dev/null 2>&1
|
||||
}
|
||||
|
||||
cosign_can_verify_attestation() {
|
||||
command -v cosign >/dev/null 2>&1 && cosign verify-blob-attestation --help >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Fail-closed provenance check. The bundle is signed Sigstore SLSA provenance
|
||||
# for this workflow; checksum verification (same-origin) is a separate step.
|
||||
# Prefer `gh attestation verify`; otherwise a local `cosign`. We do not
|
||||
# bootstrap cosign — current releases are ~140MB, which is too heavy for
|
||||
# curl|bash. Missing verifier or a failed check aborts before extract.
|
||||
verify_provenance() {
|
||||
local file=$1
|
||||
local bundle=$2
|
||||
|
||||
if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then
|
||||
echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping provenance verification (at your own risk).${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -s "$bundle" ]; then
|
||||
echo -e "${RED}✗ Missing provenance bundle ${bundle}.${NC}"
|
||||
abort_unverified
|
||||
fi
|
||||
|
||||
echo -e "${MUTED}Verifying Sigstore provenance...${NC}"
|
||||
|
||||
if gh_can_verify_attestation; then
|
||||
if gh attestation verify "$file" \
|
||||
--repo "$REPO" \
|
||||
--bundle "$bundle" \
|
||||
--signer-workflow "$SIGNER_WORKFLOW" \
|
||||
--predicate-type "https://slsa.dev/provenance/v1" \
|
||||
--deny-self-hosted-runners; then
|
||||
echo -e "${GREEN}✓ Provenance verified${NC} ${MUTED}(gh)${NC}"
|
||||
return 0
|
||||
fi
|
||||
echo -e "${RED}✗ gh attestation verify failed.${NC}"
|
||||
abort_unverified
|
||||
fi
|
||||
|
||||
if cosign_can_verify_attestation; then
|
||||
if cosign verify-blob-attestation \
|
||||
--bundle "$bundle" \
|
||||
--new-bundle-format \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
--certificate-identity-regexp "$CERT_IDENTITY_REGEXP" \
|
||||
--type slsaprovenance1 \
|
||||
"$file"; then
|
||||
echo -e "${GREEN}✓ Provenance verified${NC} ${MUTED}(cosign)${NC}"
|
||||
return 0
|
||||
fi
|
||||
echo -e "${RED}✗ cosign verify-blob-attestation failed.${NC}"
|
||||
abort_unverified
|
||||
fi
|
||||
|
||||
echo -e "${RED}✗ Neither a usable 'gh' nor 'cosign' was found; cannot verify provenance.${NC}"
|
||||
echo -e "${MUTED}Install GitHub CLI (gh) or cosign, then re-run.${NC}"
|
||||
abort_unverified
|
||||
}
|
||||
|
||||
check_version() {
|
||||
if [[ -x "$INSTALL_DIR/strix" ]]; then
|
||||
installed_version=$("$INSTALL_DIR/strix" --version 2>/dev/null | awk '{print $2}' || echo "")
|
||||
if [[ "$installed_version" == "$specific_version" ]]; then
|
||||
|
|
@ -147,7 +276,20 @@ download_and_install() {
|
|||
print_message info "\n${CYAN}🦉 Installing Strix${NC} ${MUTED}version: ${NC}$specific_version"
|
||||
print_message info "${MUTED}Platform: ${NC}$target\n"
|
||||
|
||||
local tmp_dir=$(mktemp -d)
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
|
||||
# Never leave a half-written binary in INSTALL_DIR. Stage to *.new and only
|
||||
# rename into place after a verified archive has been extracted. On any
|
||||
# abort (including verification failure), remove the staging file and the
|
||||
# download temp dir; the current install stays untouched.
|
||||
cleanup_install_temps() {
|
||||
cd / >/dev/null 2>&1 || true
|
||||
rm -rf "$tmp_dir"
|
||||
rm -f "$INSTALL_DIR/strix.new" "$INSTALL_DIR/strix.exe.new"
|
||||
}
|
||||
trap cleanup_install_temps EXIT
|
||||
|
||||
cd "$tmp_dir"
|
||||
|
||||
echo -e "${MUTED}Downloading...${NC}"
|
||||
|
|
@ -158,20 +300,41 @@ download_and_install() {
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${STRIX_INSTALL_SKIP_VERIFY:-}" ]; then
|
||||
echo -e "${YELLOW}⚠ STRIX_INSTALL_SKIP_VERIFY set — skipping checksum and provenance checks.${NC}"
|
||||
else
|
||||
echo -e "${MUTED}Downloading checksums...${NC}"
|
||||
if ! curl -sfL -o "$sums_name" "$sums_url" || [ ! -s "$sums_name" ]; then
|
||||
echo -e "${RED}✗ Failed to download checksum manifest.${NC}"
|
||||
abort_unverified
|
||||
fi
|
||||
verify_checksum "$filename"
|
||||
|
||||
echo -e "${MUTED}Downloading provenance...${NC}"
|
||||
if ! curl -sfL -o "$bundle_name" "$bundle_url" || [ ! -s "$bundle_name" ]; then
|
||||
echo -e "${RED}✗ Failed to download provenance bundle.${NC}"
|
||||
abort_unverified
|
||||
fi
|
||||
verify_provenance "$filename" "$bundle_name"
|
||||
fi
|
||||
|
||||
echo -e "${MUTED}Extracting...${NC}"
|
||||
if [ "$os" = "windows" ]; then
|
||||
unzip -q "$filename"
|
||||
mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe"
|
||||
mv "strix-${specific_version}-${target}.exe" "$INSTALL_DIR/strix.exe.new"
|
||||
mv -f "$INSTALL_DIR/strix.exe.new" "$INSTALL_DIR/strix.exe"
|
||||
else
|
||||
tar -xzf "$filename"
|
||||
mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix"
|
||||
chmod 755 "$INSTALL_DIR/strix"
|
||||
mv "strix-${specific_version}-${target}" "$INSTALL_DIR/strix.new"
|
||||
chmod 755 "$INSTALL_DIR/strix.new"
|
||||
mv -f "$INSTALL_DIR/strix.new" "$INSTALL_DIR/strix"
|
||||
fi
|
||||
|
||||
cd - > /dev/null
|
||||
rm -rf "$tmp_dir"
|
||||
trap - EXIT
|
||||
cleanup_install_temps
|
||||
|
||||
echo -e "${GREEN}✓ Strix installed to $INSTALL_DIR${NC}"
|
||||
check_existing_installation
|
||||
}
|
||||
|
||||
check_docker() {
|
||||
|
|
|
|||
191
tests/test_build_release_workflow.py
Normal file
191
tests/test_build_release_workflow.py
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"""Structural checks for release signing + checksum publishing (#1267)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
WORKFLOW = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "build-release.yml"
|
||||
ATTEST_PIN = "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6"
|
||||
|
||||
|
||||
def _workflow() -> dict[str, object]:
|
||||
return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _job(name: str) -> dict[str, object]:
|
||||
jobs = _workflow()["jobs"]
|
||||
assert isinstance(jobs, dict)
|
||||
job = jobs[name]
|
||||
assert isinstance(job, dict)
|
||||
return job
|
||||
|
||||
|
||||
def _steps(job_name: str) -> list[dict[str, object]]:
|
||||
steps = _job(job_name)["steps"]
|
||||
assert isinstance(steps, list)
|
||||
return [step for step in steps if isinstance(step, dict)]
|
||||
|
||||
|
||||
def _step_named(job_name: str, name: str) -> dict[str, object]:
|
||||
for step in _steps(job_name):
|
||||
if step.get("name") == name:
|
||||
return step
|
||||
raise AssertionError(f"missing step {name!r} in job {job_name!r}")
|
||||
|
||||
|
||||
def test_build_job_requests_attestation_permissions() -> None:
|
||||
permissions = _job("build")["permissions"]
|
||||
assert permissions == {
|
||||
"contents": "read",
|
||||
"id-token": "write",
|
||||
"attestations": "write",
|
||||
}
|
||||
|
||||
|
||||
def test_release_job_requests_attestation_permissions() -> None:
|
||||
permissions = _job("release")["permissions"]
|
||||
assert permissions == {
|
||||
"contents": "write",
|
||||
"id-token": "write",
|
||||
"attestations": "write",
|
||||
}
|
||||
|
||||
|
||||
def test_build_attests_unix_and_windows_archives_with_pinned_action() -> None:
|
||||
unix = _step_named("build", "Attest Unix archive and wheel")
|
||||
windows = _step_named("build", "Attest Windows archive and wheel")
|
||||
|
||||
assert unix["uses"] == ATTEST_PIN
|
||||
assert unix["id"] == "attest-unix"
|
||||
assert unix["if"] == "runner.os != 'Windows'"
|
||||
assert "dist/release/*.tar.gz" in str(unix["with"]["subject-path"])
|
||||
assert "dist/*.whl" in str(unix["with"]["subject-path"])
|
||||
|
||||
assert windows["uses"] == ATTEST_PIN
|
||||
assert windows["id"] == "attest-windows"
|
||||
assert windows["if"] == "runner.os == 'Windows'"
|
||||
assert "dist/release/*.zip" in str(windows["with"]["subject-path"])
|
||||
assert "dist/*.whl" in str(windows["with"]["subject-path"])
|
||||
|
||||
|
||||
def test_build_publishes_and_uploads_per_target_attestation_bundle() -> None:
|
||||
publish = _step_named("build", "Publish attestation bundle")
|
||||
assert "strix-${{ matrix.target }}.intoto.jsonl" in str(publish["run"])
|
||||
assert "attest-unix.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"])
|
||||
assert "attest-windows.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"])
|
||||
|
||||
upload = next(
|
||||
step for step in _steps("build") if "upload-artifact@" in str(step.get("uses", ""))
|
||||
)
|
||||
paths = str(upload["with"]["path"])
|
||||
assert "dist/release/*.intoto.jsonl" in paths
|
||||
assert "dist/release/*.tar.gz" in paths
|
||||
assert "dist/*.whl" in paths
|
||||
|
||||
|
||||
def test_release_generates_sha256sums_excluding_provenance_bundles() -> None:
|
||||
generate = _step_named("release", "Generate SHA256SUMS")
|
||||
assert generate["working-directory"] == "release"
|
||||
script = str(generate["run"])
|
||||
assert "SHA256SUMS" in script
|
||||
assert "! -name '*.intoto.jsonl'" in script
|
||||
assert "sha256sum" in script
|
||||
|
||||
|
||||
def test_release_attests_and_publishes_sha256sums_bundle() -> None:
|
||||
attest = _step_named("release", "Attest SHA256SUMS")
|
||||
assert attest["uses"] == ATTEST_PIN
|
||||
assert attest["id"] == "attest-sums"
|
||||
assert attest["with"]["subject-path"] == "release/SHA256SUMS"
|
||||
|
||||
publish = _step_named("release", "Publish SHA256SUMS attestation bundle")
|
||||
assert "SHA256SUMS.intoto.jsonl" in str(publish["run"])
|
||||
assert "attest-sums.outputs.bundle-path" in str(publish["env"]["BUNDLE_PATH"])
|
||||
|
||||
create = _step_named("release", "Create Release")
|
||||
assert create["with"]["files"] == "release/**"
|
||||
|
||||
|
||||
def test_every_action_use_is_sha_pinned() -> None:
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
uses = re.findall(r"^\s+uses:\s+(\S+)", text, flags=re.MULTILINE)
|
||||
assert uses
|
||||
for ref in uses:
|
||||
assert "@" in ref, ref
|
||||
digest = ref.rsplit("@", 1)[1]
|
||||
assert re.fullmatch(r"[0-9a-f]{40}", digest), ref
|
||||
|
||||
|
||||
def test_sha256sums_script_hashes_product_files_only(tmp_path: Path) -> None:
|
||||
"""Run the release job's checksum recipe against a fixture tree."""
|
||||
release_dir = tmp_path / "release"
|
||||
release_dir.mkdir()
|
||||
(release_dir / "strix-1.0.0-linux-x86_64.tar.gz").write_bytes(b"archive-bytes")
|
||||
(release_dir / "strix_agent-1.0.0-py3-none-any.whl").write_bytes(b"wheel-bytes")
|
||||
(release_dir / "strix-linux-x86_64.intoto.jsonl").write_text(
|
||||
'{"attestation":true}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
script = str(_step_named("release", "Generate SHA256SUMS")["run"])
|
||||
result = subprocess.run( # noqa: S603
|
||||
["/bin/bash", "-c", script],
|
||||
cwd=release_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
sums_path = release_dir / "SHA256SUMS"
|
||||
assert sums_path.is_file()
|
||||
lines = [line for line in sums_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
names = {line.split()[-1] for line in lines}
|
||||
assert names == {
|
||||
"strix-1.0.0-linux-x86_64.tar.gz",
|
||||
"strix_agent-1.0.0-py3-none-any.whl",
|
||||
}
|
||||
assert "strix-linux-x86_64.intoto.jsonl" not in names
|
||||
assert "SHA256SUMS" not in names
|
||||
|
||||
for line in lines:
|
||||
digest, name = line.split()
|
||||
expected = hashlib.sha256((release_dir / name).read_bytes()).hexdigest()
|
||||
assert digest == expected
|
||||
|
||||
|
||||
def test_release_notes_include_manual_verification() -> None:
|
||||
create = _step_named("release", "Create Release")
|
||||
body = str(create["with"]["body"])
|
||||
assert "## Verify this release" in body
|
||||
assert "sha256sum -c --ignore-missing SHA256SUMS" in body
|
||||
assert "gh attestation verify" in body
|
||||
assert "usestrix/strix/.github/workflows/build-release.yml" in body
|
||||
assert "docs.strix.ai/quickstart#verify-a-downloaded-release" in body
|
||||
assert create["with"]["generate_release_notes"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("job_name", "step_name"),
|
||||
[
|
||||
("build", "Attest Unix archive and wheel"),
|
||||
("build", "Attest Windows archive and wheel"),
|
||||
("release", "Attest SHA256SUMS"),
|
||||
],
|
||||
)
|
||||
def test_attest_steps_comment_mentions_pinned_version(
|
||||
job_name: str,
|
||||
step_name: str,
|
||||
) -> None:
|
||||
# Keep the SHA pin and the human version comment in sync with checkout/etc.
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
assert "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2" in text
|
||||
step = _step_named(job_name, step_name)
|
||||
assert step["uses"] == ATTEST_PIN
|
||||
|
|
@ -48,20 +48,86 @@ esac
|
|||
""",
|
||||
)
|
||||
_write_executable(mock_bin / "docker", "#!/bin/sh\nexit 0\n")
|
||||
# Shadow any real gh/cosign on PATH. Behaviour is driven by env flags.
|
||||
_write_executable(
|
||||
mock_bin / "gh",
|
||||
"""#!/bin/sh
|
||||
if [ -n "${STRIX_TEST_NO_VERIFIER:-}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$1" = "attestation" ] && [ "$2" = "verify" ]; then
|
||||
if [ "$3" = "--help" ]; then
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\\n' "$*" >> "$STRIX_TEST_GH_LOG"
|
||||
if [ -n "${STRIX_TEST_GH_FAIL_VERIFY:-}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
""",
|
||||
)
|
||||
_write_executable(
|
||||
mock_bin / "cosign",
|
||||
"""#!/bin/sh
|
||||
if [ -n "${STRIX_TEST_NO_VERIFIER:-}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ "$1" = "verify-blob-attestation" ]; then
|
||||
if [ "$2" = "--help" ]; then
|
||||
[ -n "${STRIX_TEST_USE_COSIGN:-}" ] && exit 0
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\\n' "$*" >> "$STRIX_TEST_COSIGN_LOG"
|
||||
[ -n "${STRIX_TEST_USE_COSIGN:-}" ] && exit 0
|
||||
exit 1
|
||||
fi
|
||||
exit 1
|
||||
""",
|
||||
)
|
||||
_write_executable(
|
||||
mock_bin / "curl",
|
||||
"""#!/bin/sh
|
||||
output=""
|
||||
url=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "-o" ]; then
|
||||
output="$2"
|
||||
shift 2
|
||||
continue
|
||||
fi
|
||||
case "$1" in
|
||||
http://*|https://*) url="$1" ;;
|
||||
esac
|
||||
printf '%s\\n' "$1" >> "$STRIX_TEST_CURL_LOG"
|
||||
shift
|
||||
done
|
||||
cp "$STRIX_TEST_ARCHIVE" "$output"
|
||||
if [ -n "${STRIX_TEST_CURL_FAIL:-}" ]; then
|
||||
case "$url" in
|
||||
*"$STRIX_TEST_CURL_FAIL"*) exit 22 ;;
|
||||
esac
|
||||
fi
|
||||
case "$url" in
|
||||
*/SHA256SUMS)
|
||||
name=$(basename "$STRIX_TEST_ARCHIVE")
|
||||
if [ -n "${STRIX_TEST_BAD_CHECKSUM:-}" ]; then
|
||||
printf '%s %s\\n' "0" "$name" > "$output"
|
||||
elif [ -n "${STRIX_TEST_SUMS_WRONG_NAME:-}" ]; then
|
||||
hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
|
||||
printf '%s %s\\n' "$hash" "other-file.tar.gz" > "$output"
|
||||
else
|
||||
hash=$(sha256sum "$STRIX_TEST_ARCHIVE" | awk '{print $1}')
|
||||
printf '%s %s\\n' "$hash" "$name" > "$output"
|
||||
fi
|
||||
;;
|
||||
*.intoto.jsonl)
|
||||
printf '{"test":true}\\n' > "$output"
|
||||
;;
|
||||
*)
|
||||
cp "$STRIX_TEST_ARCHIVE" "$output"
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
)
|
||||
return mock_bin
|
||||
|
|
@ -83,6 +149,8 @@ def _create_installer_environment(
|
|||
download_path = tmp_path / "downloads"
|
||||
download_path.mkdir()
|
||||
curl_log_path = tmp_path / "curl.log"
|
||||
gh_log_path = tmp_path / "gh.log"
|
||||
cosign_log_path = tmp_path / "cosign.log"
|
||||
environment = {
|
||||
"HOME": str(home_path),
|
||||
"XDG_CONFIG_HOME": str(home_path / ".config"),
|
||||
|
|
@ -91,6 +159,8 @@ def _create_installer_environment(
|
|||
"TMPDIR": str(download_path),
|
||||
"STRIX_TEST_ARCHIVE": str(archive_path),
|
||||
"STRIX_TEST_CURL_LOG": str(curl_log_path),
|
||||
"STRIX_TEST_GH_LOG": str(gh_log_path),
|
||||
"STRIX_TEST_COSIGN_LOG": str(cosign_log_path),
|
||||
"VERSION": RELEASE_VERSION,
|
||||
}
|
||||
return environment, home_path, curl_log_path
|
||||
|
|
@ -124,7 +194,18 @@ def test_installer_downloads_and_runs_linux_arm64_release(tmp_path: Path) -> Non
|
|||
|
||||
assert result.returncode == 0, result.stderr
|
||||
expected_filename = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}.tar.gz"
|
||||
assert expected_filename in curl_log_path.read_text(encoding="utf-8")
|
||||
curl_log = curl_log_path.read_text(encoding="utf-8")
|
||||
assert expected_filename in curl_log
|
||||
assert f"releases/download/v{RELEASE_VERSION}/SHA256SUMS" in curl_log
|
||||
assert f"strix-{RELEASE_TARGET}.intoto.jsonl" in curl_log
|
||||
assert "Checksum verified" in result.stdout
|
||||
assert "Provenance verified" in result.stdout
|
||||
gh_log = (tmp_path / "gh.log").read_text(encoding="utf-8")
|
||||
assert "--repo usestrix/strix" in gh_log
|
||||
assert "--bundle strix-linux-arm64.intoto.jsonl" in gh_log
|
||||
assert "--signer-workflow usestrix/strix/.github/workflows/build-release.yml" in gh_log
|
||||
assert "--deny-self-hosted-runners" in gh_log
|
||||
assert not (tmp_path / "cosign.log").exists()
|
||||
|
||||
installed_binary = home_path / ".strix/bin/strix"
|
||||
installed_result = subprocess.run( # noqa: S603
|
||||
|
|
@ -152,3 +233,216 @@ def test_installer_rejects_unsupported_architecture(tmp_path: Path) -> None:
|
|||
assert "Unsupported OS/Arch: linux/riscv64" in result.stdout
|
||||
assert not curl_log_path.exists()
|
||||
assert not (home_path / ".strix").exists()
|
||||
|
||||
|
||||
def test_installer_leaves_external_strix_on_checksum_failure(tmp_path: Path) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, _curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_TEST_BAD_CHECKSUM"] = "1"
|
||||
|
||||
other_bin = tmp_path / "other-bin"
|
||||
other_bin.mkdir()
|
||||
external = other_bin / "strix"
|
||||
_write_executable(external, "#!/bin/sh\nprintf 'strix 1.0.0\\n'\n")
|
||||
environment["PATH"] = f"{other_bin}:{environment['PATH']}"
|
||||
before = external.read_bytes()
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Checksum mismatch" in result.stdout
|
||||
assert external.exists()
|
||||
assert external.read_bytes() == before
|
||||
assert not (home_path / ".strix/bin/strix").exists()
|
||||
installed_result = subprocess.run( # noqa: S603
|
||||
[str(external), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert installed_result.stdout.strip() == "strix 1.0.0"
|
||||
|
||||
|
||||
def test_installer_leaves_existing_install_on_checksum_failure(tmp_path: Path) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, _curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_TEST_BAD_CHECKSUM"] = "1"
|
||||
|
||||
install_dir = home_path / ".strix" / "bin"
|
||||
install_dir.mkdir(parents=True)
|
||||
existing = install_dir / "strix"
|
||||
_write_executable(existing, "#!/bin/sh\nprintf 'strix 1.0.0\\n'\n")
|
||||
before = existing.read_bytes()
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Checksum mismatch" in result.stdout
|
||||
assert "Existing Strix installation left unchanged" in result.stdout
|
||||
assert existing.read_bytes() == before
|
||||
assert not (install_dir / "strix.new").exists()
|
||||
installed_result = subprocess.run( # noqa: S603
|
||||
[str(existing), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
assert installed_result.stdout.strip() == "strix 1.0.0"
|
||||
|
||||
|
||||
def test_installer_rejects_missing_checksum_manifest(tmp_path: Path) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, _curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_TEST_CURL_FAIL"] = "SHA256SUMS"
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Failed to download checksum manifest" in result.stdout
|
||||
assert not (home_path / ".strix/bin/strix").exists()
|
||||
|
||||
|
||||
def test_installer_rejects_checksum_manifest_without_archive_entry(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, _curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_TEST_SUMS_WRONG_NAME"] = "1"
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "No SHA256SUMS entry" in result.stdout
|
||||
assert not (home_path / ".strix/bin/strix").exists()
|
||||
|
||||
|
||||
def test_installer_rejects_missing_provenance_bundle(tmp_path: Path) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, _curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_TEST_CURL_FAIL"] = "intoto.jsonl"
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Failed to download provenance bundle" in result.stdout
|
||||
assert "Checksum verified" in result.stdout
|
||||
assert not (home_path / ".strix/bin/strix").exists()
|
||||
|
||||
|
||||
def test_installer_rejects_failed_gh_attestation_verify(tmp_path: Path) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, _curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_TEST_GH_FAIL_VERIFY"] = "1"
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "gh attestation verify failed" in result.stdout
|
||||
assert not (home_path / ".strix/bin/strix").exists()
|
||||
assert not (tmp_path / "cosign.log").exists()
|
||||
|
||||
|
||||
def test_installer_uses_cosign_when_gh_cannot_verify(tmp_path: Path) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, _curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_TEST_USE_COSIGN"] = "1"
|
||||
_write_executable(
|
||||
mock_bin / "gh",
|
||||
"#!/bin/sh\nexit 1\n",
|
||||
)
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Provenance verified" in result.stdout
|
||||
assert "(cosign)" in result.stdout
|
||||
cosign_log = (tmp_path / "cosign.log").read_text(encoding="utf-8")
|
||||
assert "--new-bundle-format" in cosign_log
|
||||
assert "--certificate-oidc-issuer https://token.actions.githubusercontent.com" in (cosign_log)
|
||||
assert "build-release.yml" in cosign_log
|
||||
assert "--type slsaprovenance1" in cosign_log
|
||||
assert (home_path / ".strix/bin/strix").exists()
|
||||
|
||||
|
||||
def test_installer_rejects_when_no_provenance_verifier_is_available(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, _curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_TEST_NO_VERIFIER"] = "1"
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "Neither a usable 'gh' nor 'cosign'" in result.stdout
|
||||
assert not (home_path / ".strix/bin/strix").exists()
|
||||
|
||||
|
||||
def test_installer_skip_verify_does_not_download_manifests(tmp_path: Path) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
archive_path = _create_release_archive(tmp_path)
|
||||
mock_bin = _create_mock_commands(tmp_path, machine="aarch64")
|
||||
environment, home_path, curl_log_path = _create_installer_environment(
|
||||
tmp_path,
|
||||
archive_path,
|
||||
mock_bin,
|
||||
)
|
||||
environment["STRIX_INSTALL_SKIP_VERIFY"] = "1"
|
||||
|
||||
result = _run_installer(repository_root, environment)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
curl_log = curl_log_path.read_text(encoding="utf-8")
|
||||
assert "SHA256SUMS" not in curl_log
|
||||
assert "intoto.jsonl" not in curl_log
|
||||
assert "skipping checksum and provenance checks" in result.stdout
|
||||
assert (home_path / ".strix/bin/strix").exists()
|
||||
assert not (tmp_path / "gh.log").exists()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue