38f14deeff
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Catalog signature (pull_request) Successful in 7s
Fixes findings 2, 3, 4, 6, 7 from the issue #68 adversarial review. - Finding 2: config.env was type-checked only. Add CATALOG_DENIED_ENV_KEYS (case-insensitive) for interpreter/loader-override keys (NODE_OPTIONS, PYTHONPATH, LD_PRELOAD, ...), apply the ASCII check and the existing secret-value check to env keys/values, and require env values to be empty or a single <PLACEHOLDER> token. - Finding 3: version pinning was only checked by catalog_review.py (which never runs on the signing path per finding 1). Move enforcement into _validate_catalog_config: npm/uvx specs must carry @version or ==version (scoped names handled), docker images must have an explicit non-latest tag. Only the first plausible package-spec token is checked, so flags, <PLACEHOLDER>s, and docker subcommands/flags don't trip it. All 19 real catalog entries still validate clean. - Finding 4: the CI catalog-signature gate imported bcc_core from the PR branch and trusted whatever CATALOG_PUBKEYS said there, so a PR changing both catalog.json and CATALOG_PUBKEYS (with a matching signature) went green. ci.yml now hardcodes the expected base64 pubkey and asserts bcc_core.CATALOG_PUBKEYS matches it before verifying the signature. NOTE: the maintainer is planning to rotate this key -- update EXPECTED_CATALOG_PUBKEY_B64 in ci.yml as its own reviewed change when that happens, never bundled with a catalog content change. - Finding 6: resolve_catalog's anti-rollback/anti-freeze guards sat behind `if best_version >= 0`, so the first verified candidate was accepted unconditionally and the anti-freeze anchor drifted with each accepted candidate instead of staying fixed. The cap is now measured against the bundled catalog's version specifically (the trust anchor baked into the binary), regardless of evaluation order; bundled wins version ties; and a new pure `floor` parameter lets a future caller pass a persisted accepted-version floor. - Finding 7: catalog id is now constrained to ^[a-z0-9][a-z0-9._-]{0,63}$. Tests: fixed _minimal_catalog to use a pinned package (was enshrining finding 3), rewrote the env-passthrough test to prove the validation boundary instead of asserting env passes through unchecked, and reordered test_resolve_catalog_rejects_absurd_version_jump so it actually exercises the first-candidate path. Added positive/negative tests for every new rule. Manually verified each new check by commenting it out and confirming the guarding test goes red, then restoring it.
177 lines
7.2 KiB
YAML
177 lines
7.2 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
workflow_dispatch:
|
|
|
|
jobs:
|
|
lint:
|
|
runs-on: ubuntu-latest
|
|
name: Lint (ruff)
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Python 3.12
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install ruff
|
|
run: pip install ruff
|
|
|
|
- name: ruff check
|
|
run: ruff check .
|
|
|
|
- name: ruff format --check
|
|
run: ruff format --check .
|
|
|
|
test:
|
|
runs-on: ${{ matrix.os }}
|
|
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-latest]
|
|
python: ["3.10", "3.12", "3.13"]
|
|
include:
|
|
# Windows tests on 3.12 only — the version the release binaries ship
|
|
# with. The self-hosted Windows runner blocks setup-python's install
|
|
# script (PowerShell execution policy), so it uses the host's `py`
|
|
# launcher + venv, same as release.yml.
|
|
- os: windows-latest
|
|
python: "3.12"
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Python ${{ matrix.python }} (Linux)
|
|
if: runner.os == 'Linux'
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: ${{ matrix.python }}
|
|
|
|
- name: Set up Python venv (Windows)
|
|
if: runner.os == 'Windows'
|
|
shell: pwsh
|
|
run: |
|
|
py -${{ matrix.python }} -m venv .venv
|
|
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
|
|
|
|
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
|
|
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
|
# cryptography is for tests/test_checksums.py (release signing helper).
|
|
- name: Install test dependencies
|
|
run: pip install pytest cryptography
|
|
|
|
- name: Run tests
|
|
run: python -m pytest -v
|
|
|
|
# ── Catalog signature gate (#61) ─────────────────────────────────────────
|
|
#
|
|
# data/catalog.json is a list of command+args entries that BCC writes into
|
|
# the user's Claude config, which Claude then EXECUTES. The catalog is only
|
|
# trusted if it carries a valid Ed25519 signature from the maintainer key.
|
|
#
|
|
# The threat this gate exists for is NOT an outsider pushing to the repo —
|
|
# it is the maintainer merging a friendly-looking PR without really reading
|
|
# it. A contributor can change catalog.json but cannot produce a matching
|
|
# signature, so a blindly-merged PR lands here as a RED BUILD within a
|
|
# minute, instead of quietly riding into the next release.
|
|
#
|
|
# Public-key verification only. No secret is used or needed.
|
|
catalog-signature:
|
|
name: Catalog signature
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install dependencies
|
|
run: pip install cryptography
|
|
|
|
# 🔴 TRUST ANCHOR — issue #68 finding 4.
|
|
#
|
|
# This step used to do `import bcc_core as c` FROM THE CHECKED-OUT PR
|
|
# BRANCH and verify the catalog against c.CATALOG_PUBKEYS — i.e. it
|
|
# trusted the public key shipped in the very diff it was reviewing. A
|
|
# PR that changed data/catalog.json AND bcc_core.CATALOG_PUBKEYS (to
|
|
# an attacker key, with a matching signature produced by the attacker's
|
|
# matching private key) went green, because there was nothing outside
|
|
# the PR's own content to check the key against. The gate's whole
|
|
# point is catching a friendly-looking PR the maintainer merges
|
|
# without really reading it — and that hole made it a two-file diff.
|
|
#
|
|
# EXPECTED_CATALOG_PUBKEY_B64 below is hardcoded HERE, in the workflow
|
|
# file, independent of whatever bcc_core.py says on the PR branch. It
|
|
# is intentionally the only line in this step that matters for
|
|
# security review: changing it changes what this gate is willing to
|
|
# trust. THIS CONSTANT IS A TRUST ANCHOR. A PR that changes this line
|
|
# in the same diff as a catalog change is exactly the attack this gate
|
|
# exists to prevent — review a change to this line on its own,
|
|
# never bundled with a catalog update.
|
|
#
|
|
# NOTE for the next key rotation: update EXPECTED_CATALOG_PUBKEY_B64
|
|
# below to the new key's base64 form, as its own reviewed change.
|
|
- name: Verify data/catalog.json.sig
|
|
env:
|
|
EXPECTED_CATALOG_PUBKEY_B64: "082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="
|
|
run: |
|
|
python - <<'PY'
|
|
import base64, os, pathlib, sys
|
|
import bcc_core as c
|
|
|
|
expected_pubkey_b64 = os.environ["EXPECTED_CATALOG_PUBKEY_B64"]
|
|
|
|
raw = pathlib.Path("data/catalog.json").read_bytes()
|
|
sig_path = pathlib.Path("data/catalog.json.sig")
|
|
|
|
if not sig_path.exists():
|
|
sys.exit("FAIL: data/catalog.json.sig is missing. The catalog must be "
|
|
"signed via the Catalog Console (#62) before it can land.")
|
|
|
|
if b"\x00" * 32 in c.CATALOG_PUBKEYS:
|
|
sys.exit("FAIL: CATALOG_PUBKEYS still holds the placeholder key.")
|
|
|
|
# Trust anchor check FIRST, before verifying anything against
|
|
# bcc_core.CATALOG_PUBKEYS: a PR is not allowed to bring its own
|
|
# key. CATALOG_PUBKEYS on the checked-out branch must be EXACTLY
|
|
# the key(s) this workflow file itself expects -- no more, no
|
|
# fewer, no substitutions.
|
|
actual_pubkeys_b64 = [base64.b64encode(k).decode() for k in c.CATALOG_PUBKEYS]
|
|
if actual_pubkeys_b64 != [expected_pubkey_b64]:
|
|
sys.exit(
|
|
"FAIL: bcc_core.CATALOG_PUBKEYS on this branch does not match the "
|
|
"trust anchor hardcoded in .github/workflows/ci.yml.\n"
|
|
f" expected: {[expected_pubkey_b64]}\n"
|
|
f" actual: {actual_pubkeys_b64}\n"
|
|
"\n"
|
|
"This PR is changing (or has changed) the catalog signing key. That "
|
|
"change must be reviewed on its own, separately from any catalog "
|
|
"content change, and the workflow's EXPECTED_CATALOG_PUBKEY_B64 "
|
|
"updated deliberately -- not accepted because it happened to match "
|
|
"whatever bcc_core.py says on this branch."
|
|
)
|
|
|
|
if not c.verify_catalog_signature(raw, sig_path.read_bytes(), c.CATALOG_PUBKEYS):
|
|
sys.exit(
|
|
"FAIL: data/catalog.json does NOT match its signature.\n"
|
|
"\n"
|
|
"The catalog changed without being re-signed. Either someone edited\n"
|
|
"it directly (a PR you merged?), or a signing pass was forgotten.\n"
|
|
"Re-review and re-sign with the Catalog Console — do not bypass this."
|
|
)
|
|
|
|
problems = c.validate_catalog(c.load_catalog(raw))
|
|
if problems:
|
|
sys.exit("FAIL: catalog failed validation:\n " + "\n ".join(problems))
|
|
|
|
print("OK: catalog signature verifies, the pubkey matches the CI trust anchor, "
|
|
"and the catalog validates clean.")
|
|
PY
|