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
50 changes: 50 additions & 0 deletions .claude/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,56 @@ The format is based on the regulated environment requirements:

---

## [2026-04-20 00:15] - Grant `update` on `scheduledmachines/finalizers` to unblock Machine creation

**Author:** Erick Bourgeois

### Changed
- `deploy/deployment/rbac/clusterrole.yaml`: New rule granting `update` on `scheduledmachines/finalizers` under the `5spot.finos.org` API group. Added as a separate rule (rather than widening the existing `scheduledmachines` / `scheduledmachines/status` rule) so the verb surface on the finalizers subresource stays exactly at the minimum the API-server admission check requires. Inline comment documents the link back to `src/reconcilers/helpers.rs` where `blockOwnerDeletion: true` is set, and quotes the exact API-server error that the missing rule produces.

### Why
`src/reconcilers/helpers.rs:859` stamps `blockOwnerDeletion: true` on the `ownerReference` it writes from every child CAPI `Machine` back to its parent `ScheduledMachine`. Kubernetes treats `blockOwnerDeletion` as functionally equivalent to holding a finalizer on the owner, so during admission it checks that the creating service account has `update` on `<owner-resource>/finalizers` — a separate subresource in RBAC terms from the main `scheduledmachines` resource and from `scheduledmachines/status`. The controller's ClusterRole has shipped since the initial commit with full CRUD on `scheduledmachines` and `scheduledmachines/status` but without any rule for the `finalizers` subresource, so every Machine creation is rejected by the API server with `machines.cluster.x-k8s.io "<name>" is forbidden: cannot set blockOwnerDeletion if an ownerReference refers to a resource you can't set finalizers on`. This is a latent RBAC bug, not a regression. The fix grants exactly the verb the API server checks (`update`); no broader verb set is needed and granting more would violate least-privilege.

### Impact
- [ ] Breaking change
- [x] Requires cluster rollout (re-apply the ClusterRole; no controller restart required — RBAC changes are picked up on the next API request)
- [ ] Config change only
- [ ] Documentation only

### Follow-up (optional, not in this commit)
- Integration test that creates a `ScheduledMachine` against a kind cluster with the shipped ClusterRole and asserts the child `Machine` is admitted. Would catch any future regression in RBAC vs. `ownerReference` shape without waiting for a prod cluster rollout to expose it.

---

## [2026-04-19 21:50] - Triage GHSA-cq8v-f236-94qc (rand soundness); widen VEX identifier shapes

**Author:** Erick Bourgeois

### Changed
- `osv-scanner.toml` (new, at repo root): Ignore entry for `GHSA-cq8v-f236-94qc` with `ignoreUntil = 2026-10-19` and a reason that mirrors the VEX statement. This is the file shape Scorecard's `Vulnerabilities` check (and osv-scanner itself) expect — per the remediation text in the Scorecard finding.
- `.vex/GHSA-cq8v-f236-94qc.toml` (new): OpenVEX statement with `status = not_affected`, `justification = vulnerable_code_not_in_execute_path`, explicit impact statement. Applies to both the Chainguard and Distroless product identifiers.
- `tools/validate_vex.py`: Widened the accepted identifier set from `CVE-YYYY-NNNN+` only to also accept `GHSA-xxxx-xxxx-xxxx` (case-insensitive) and `RUSTSEC-YYYY-NNNN`. The TOML field is still named `cve` for backward compatibility with the existing 11 statement files. New `_is_accepted_id()` helper composes the three regexes; error message updated to list all three shapes.
- `tools/assemble_openvex.py`: Dropped the `.upper()` on `doc["cve"]` when rendering `vulnerability.name`. GHSA ID segments are canonically lowercase and upper-casing them breaks round-tripping against osv.dev / github.com/advisories. CVE IDs in existing files are already uppercase, so this is a no-op for them.
- `tools/tests/validate-vex-tests.sh`: Added happy-path cases `valid-ghsa` and `valid-rustsec`, plus negative cases `invalid-ghsa-format`, `invalid-rustsec-format`, and `duplicate-ghsa` (uniqueness check across case variants of a GHSA ID).
- `tools/tests/fixtures/{valid-ghsa,valid-rustsec,invalid-ghsa-format,invalid-rustsec-format,duplicate-ghsa}/` (new fixture dirs).
- `tools/tests/assemble-openvex-tests.sh`: Added regression guard asserting `GHSA-cq8v-f236-94qc` is emitted verbatim (not upper-cased) into `vulnerability.name`.
- `.vex/README.md`: Documented the expanded identifier format.

### Why
OpenSSF Scorecard's `Vulnerabilities` rule flagged `GHSA-cq8v-f236-94qc` (rand soundness when a custom `log` logger calls `rand::rng()` mid-reseed) on `rand 0.8.6`, pulled transitively via `warp 0.3.7` → `tokio-tungstenite 0.21` → `tungstenite 0.21` where rand is used exclusively for websocket frame masking. The vulnerable path requires the `log` logger itself to call into rand during a reseed, which 5-Spot's tracing-subscriber stack never does. The advisory ships without a CVE ID, which the existing VEX validator (`CVE-YYYY-NNNN+` only) rejected — making it the first advisory the VEX pipeline could not represent. Widening the validator to accept GHSA + RUSTSEC identifiers unblocks this triage and every future non-CVE advisory without forcing a field rename of the existing 11 files. The full fix (rand >= 0.9.3) requires crossing a warp 0.3.x semver boundary (warp 0.3 hard-pins tokio-tungstenite ^0.21) and is tracked as a follow-up, not attempted here.

### Impact
- [ ] Breaking change
- [ ] Requires cluster rollout
- [x] Config change only (CI / supply-chain)
- [ ] Documentation only

### Follow-up (optional, not in this commit)
- Warp 0.3 → 0.4 or axum migration so `tokio-tungstenite` and the transitive `rand` can be bumped past the vulnerable range; remove `osv-scanner.toml` entry and the VEX statement when that lands.
- Consider renaming the TOML field from `cve` to `id` once every existing statement has been regenerated; not worth the churn today.

---

## [2026-04-19 21:25] - Remove bogus `vexctl validate` step from build-vex job

**Author:** Erick Bourgeois
Expand Down
33 changes: 33 additions & 0 deletions .vex/GHSA-cq8v-f236-94qc.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# rand soundness when a custom `log` logger calls rand::rng() and triggers
# ThreadRng reseeding at warn- or trace-level.
#
# Reachable-only-if ALL of:
# 1. the `log` and `thread_rng` rand features are enabled,
# 2. a custom logger implementation is registered,
# 3. that logger itself calls rand::rng() / a TryRng method on ThreadRng,
# 4. ThreadRng reseeds (every 64 kB) while the logger is on the stack,
# 5. trace-level is on OR warn-level is on with getrandom unavailable.
#
# In 5-Spot's dependency graph, the only vulnerable rand 0.8.6 is pulled
# transitively via warp 0.3.7 → tokio-tungstenite 0.21 → tungstenite 0.21,
# where rand is used exclusively for websocket client frame-masking keys
# (tungstenite::protocol::frame::mask). That code path is not invoked
# from, or reachable by, any `log` / `tracing` logger implementation in
# this project — 5-Spot uses tracing-subscriber's stock fmt + json
# layers with env-filter, none of which touch rand from within emit().
#
# The full fix (rand >= 0.9.3) requires warp → tokio-tungstenite 0.22+,
# which warp 0.3.x does not support. Tracked for removal when the warp
# 0.3 → 0.4 / axum migration lands; until then this statement documents
# the non-exploitability and a Scorecard-side ignore is recorded in
# osv-scanner.toml at the repo root.
cve = "GHSA-cq8v-f236-94qc"
status = "not_affected"
justification = "vulnerable_code_not_in_execute_path"
impact_statement = "Vulnerable rand 0.8.6 is pulled transitively by tungstenite 0.21 for websocket frame masking only. The advisory requires ThreadRng to be invoked from within a custom log::Log implementation during reseed; 5-Spot's tracing-subscriber stack does not call into rand from any logger emit path, so the unsound code is not in the execute path."
products = [
"pkg:oci/5-spot-chainguard",
"pkg:oci/5-spot-distroless",
]
author = "erick.bourgeois@gmail.com"
timestamp = "2026-04-19T00:00:00Z"
12 changes: 10 additions & 2 deletions .vex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,16 @@ statement is explicitly authored and reviewed.

## File format

One TOML file per CVE, named `<CVE-ID>.toml` (case-insensitive match on the
`cve` field; file name is informational).
One TOML file per advisory, named `<identifier>.toml` (case-insensitive match
on the `cve` field; file name is informational). Accepted identifier shapes:

- `CVE-YYYY-NNNN+` — MITRE CVE (the common case).
- `GHSA-xxxx-xxxx-xxxx` — GitHub Security Advisory. Use this when the advisory
has no assigned CVE yet (e.g. `GHSA-cq8v-f236-94qc`).
- `RUSTSEC-YYYY-NNNN` — RustSec advisory DB.

The TOML field is still named `cve` for backward compatibility with the
original file format; the value may be any of the above.

```toml
cve = "CVE-2025-12345"
Expand Down
16 changes: 16 additions & 0 deletions deploy/deployment/rbac/clusterrole.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ rules:
resources: ["scheduledmachines", "scheduledmachines/status"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

# ScheduledMachine finalizers — required because the controller sets
# `blockOwnerDeletion: true` on the ownerReference it writes from every
# child CAPI Machine back to its parent ScheduledMachine
# (src/reconcilers/helpers.rs). The API server treats blockOwnerDeletion
# as equivalent to holding a finalizer on the owner, so it checks that
# the creating identity has `update` on <owner-resource>/finalizers.
# Without this rule, Machine creation is rejected with:
# cannot set blockOwnerDeletion if an ownerReference refers to a
# resource you can't set finalizers on
# Verb is intentionally just `update` — the minimum the subresource
# check requires; the broader CRUD set on the main resource above is
# not needed here.
- apiGroups: ["5spot.finos.org"]
resources: ["scheduledmachines/finalizers"]
verbs: ["update"]

# CAPI Machine management
- apiGroups: ["cluster.x-k8s.io"]
resources: ["machines", "machines/status"]
Expand Down
36 changes: 36 additions & 0 deletions osv-scanner.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# osv-scanner.toml — OSV-Scanner ignore list.
#
# This file is consumed by osv-scanner (and, transitively, by the OpenSSF
# Scorecard `Vulnerabilities` check, which runs osv-scanner against the
# lockfiles it finds in the repository). Entries listed here are reported
# as informational but do not fail the scan, so Scorecard can restore the
# Vulnerabilities score to 10/10 while we carry the underlying transitive
# dependency.
#
# Every ignore MUST include:
# - `id` — the advisory ID (CVE / GHSA / RUSTSEC).
# - `reason` — a short justification mirroring the matching
# statement in `.vex/`. If the reason here and
# the VEX impact_statement drift, the VEX doc is
# the source of truth; keep this file aligned.
# - `ignoreUntil` — a reassessment date. Past that date osv-scanner
# will resume treating the advisory as unignored,
# forcing a re-triage.
#
# File format reference:
# https://google.github.io/osv-scanner/configuration/

[[IgnoredVulns]]
id = "GHSA-cq8v-f236-94qc"
ignoreUntil = "2026-10-19"
reason = """
rand 0.8.6 is pulled transitively via warp 0.3.7 -> tokio-tungstenite 0.21 ->
tungstenite 0.21 and is used exclusively for websocket frame masking. The
advisory requires ThreadRng to be invoked from inside a custom log::Log
implementation during reseed; 5-Spot's tracing-subscriber stack never
calls rand from a logger emit path, so the unsound code is not in the
execute path. Full remediation requires warp 0.3 -> 0.4 / axum migration
to pick up tokio-tungstenite 0.22+ (rand >= 0.9.3). Triage recorded in
.vex/GHSA-cq8v-f236-94qc.toml (OpenVEX: not_affected,
vulnerable_code_not_in_execute_path). Reassess by 2026-10-19.
"""
7 changes: 6 additions & 1 deletion tools/assemble_openvex.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ def _load_statement(path: Path) -> dict:
with path.open("rb") as fh:
doc = tomllib.load(fh)

# Preserve the identifier verbatim — CVE-YYYY-NNNN+ is uppercase by
# MITRE convention, but GHSA-xxxx-xxxx-xxxx segments are lowercase,
# and upper-casing them breaks round-tripping against osv.dev and
# github.com/advisories (which treat GHSA IDs case-insensitively for
# matching but render them in their canonical lowercase form).
statement: dict = {
"vulnerability": {"name": doc["cve"].upper()},
"vulnerability": {"name": doc["cve"]},
"products": [{"@id": product} for product in doc["products"]],
"status": doc["status"],
"timestamp": _ts_to_str(doc["timestamp"]),
Expand Down
19 changes: 19 additions & 0 deletions tools/tests/assemble-openvex-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ else
fail_case "missing --id / --author rejected" "$TMP/err"
fi

# ── GHSA identifier case is preserved (not upper-cased) ────────────────────
# GHSA IDs are conventionally lowercase alphanumeric segments; upper-casing
# them changes the on-wire identifier and breaks a round-trip against
# osv.dev / github.com/advisories. Regression guard for the rand soundness
# advisory (GHSA-cq8v-f236-94qc) which was the first non-CVE ID we shipped.
out_ghsa="$TMP/ghsa.json"
python3 "$ASSEMBLER" \
--vex-dir "$FIXTURES/valid-ghsa" \
--id "urn:vex:ghsa-test" \
--author "ci@example" \
--timestamp "2026-04-19T00:00:00Z" \
--output "$out_ghsa" >/dev/null 2>"$TMP/err"
if grep -q '"GHSA-cq8v-f236-94qc"' "$out_ghsa" &&
! grep -q '"GHSA-CQ8V-F236-94QC"' "$out_ghsa"; then
pass_case "GHSA identifier case is preserved verbatim"
else
fail_case "GHSA identifier case is preserved verbatim" "$out_ghsa"
fi

# ── Default timestamp (no --timestamp) produces RFC-3339 UTC ───────────────
out_dt="$TMP/dt.json"
python3 "$ASSEMBLER" \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
cve = "GHSA-CQ8V-F236-94QC"
status = "not_affected"
justification = "vulnerable_code_not_in_execute_path"
products = ["pkg:oci/5-spot-chainguard"]
author = "erick.bourgeois@gmail.com"
timestamp = "2026-04-19T00:00:00Z"
6 changes: 6 additions & 0 deletions tools/tests/fixtures/duplicate-ghsa/GHSA-cq8v-f236-94qc.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
cve = "GHSA-cq8v-f236-94qc"
status = "not_affected"
justification = "vulnerable_code_not_in_execute_path"
products = ["pkg:oci/5-spot-chainguard"]
author = "erick.bourgeois@gmail.com"
timestamp = "2026-04-19T00:00:00Z"
5 changes: 5 additions & 0 deletions tools/tests/fixtures/invalid-ghsa-format/GHSA-bad.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
cve = "GHSA-not-right"
status = "fixed"
products = ["pkg:oci/5-spot-chainguard"]
author = "erick.bourgeois@gmail.com"
timestamp = "2026-04-19T00:00:00Z"
5 changes: 5 additions & 0 deletions tools/tests/fixtures/invalid-rustsec-format/RUSTSEC-bad.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
cve = "RUSTSEC-2026-97"
status = "fixed"
products = ["pkg:oci/5-spot-chainguard"]
author = "erick.bourgeois@gmail.com"
timestamp = "2026-04-19T00:00:00Z"
7 changes: 7 additions & 0 deletions tools/tests/fixtures/valid-ghsa/GHSA-cq8v-f236-94qc.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
cve = "GHSA-cq8v-f236-94qc"
status = "not_affected"
justification = "vulnerable_code_not_in_execute_path"
impact_statement = "The affected code path is never invoked by 5-Spot."
products = ["pkg:oci/5-spot-chainguard"]
author = "erick.bourgeois@gmail.com"
timestamp = "2026-04-19T00:00:00Z"
7 changes: 7 additions & 0 deletions tools/tests/fixtures/valid-rustsec/RUSTSEC-2026-0097.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
cve = "RUSTSEC-2026-0097"
status = "not_affected"
justification = "vulnerable_code_not_in_execute_path"
impact_statement = "The affected code path is never invoked by 5-Spot."
products = ["pkg:oci/5-spot-chainguard"]
author = "erick.bourgeois@gmail.com"
timestamp = "2026-04-19T00:00:00Z"
8 changes: 8 additions & 0 deletions tools/tests/validate-vex-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ run_case "empty-dir" "$FIXTURES/empty-dir" 0
run_case "valid-single" "$FIXTURES/valid-single" 0
run_case "valid-multiple" "$FIXTURES/valid-multiple" 0
run_case "valid-affected" "$FIXTURES/valid-affected" 0
# Non-CVE identifiers (GHSA + RUSTSEC) accepted since 2026-04-20 —
# first real encounter was GHSA-cq8v-f236-94qc (rand soundness) which
# ships without a CVE ID.
run_case "valid-ghsa" "$FIXTURES/valid-ghsa" 0
run_case "valid-rustsec" "$FIXTURES/valid-rustsec" 0

# Negative paths — one per validation rule.
run_case "malformed-toml" "$FIXTURES/malformed-toml" 1
Expand All @@ -56,13 +61,16 @@ run_case "missing-products" "$FIXTURES/missing-products" 1
run_case "missing-author" "$FIXTURES/missing-author" 1
run_case "missing-timestamp" "$FIXTURES/missing-timestamp" 1
run_case "invalid-cve-format" "$FIXTURES/invalid-cve-format" 1
run_case "invalid-ghsa-format" "$FIXTURES/invalid-ghsa-format" 1
run_case "invalid-rustsec-format" "$FIXTURES/invalid-rustsec-format" 1
run_case "invalid-status" "$FIXTURES/invalid-status" 1
run_case "empty-products" "$FIXTURES/empty-products" 1
run_case "bad-timestamp" "$FIXTURES/bad-timestamp" 1
run_case "missing-justification" "$FIXTURES/missing-justification" 1
run_case "invalid-justification" "$FIXTURES/invalid-justification" 1
run_case "missing-action-statement" "$FIXTURES/missing-action-statement" 1
run_case "duplicate-cve" "$FIXTURES/duplicate-cve" 1
run_case "duplicate-ghsa" "$FIXTURES/duplicate-ghsa" 1

echo ""
echo "Results: $PASS passed, $FAIL failed"
Expand Down
24 changes: 22 additions & 2 deletions tools/validate_vex.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,25 @@
# Fields required on every file regardless of status.
REQUIRED_ALWAYS = ("cve", "status", "products", "author", "timestamp")

# Accepted vulnerability identifiers. The TOML field is still named `cve`
# for backward compatibility with the existing .vex/ files, but the value
# may be any of:
# - CVE-YYYY-NNNN+ (MITRE CVE)
# - GHSA-xxxx-xxxx-xxxx (GitHub Security Advisory, case-insensitive)
# - RUSTSEC-YYYY-NNNN (RustSec advisory DB)
# First real non-CVE encounter was GHSA-cq8v-f236-94qc (rand soundness)
# which ships without a CVE ID, so CVE-only would make it unrepresentable.
_CVE_RE = re.compile(r"^CVE-\d{4}-\d{4,}$")
_GHSA_RE = re.compile(r"^GHSA(-[a-z0-9]{4}){3}$", re.IGNORECASE)
_RUSTSEC_RE = re.compile(r"^RUSTSEC-\d{4}-\d{4}$")


def _is_accepted_id(value: str) -> bool:
return bool(
_CVE_RE.match(value)
or _GHSA_RE.match(value)
or _RUSTSEC_RE.match(value)
)

# RFC-3339 in UTC (Z suffix) — we intentionally keep this narrow so every VEX
# timestamp is directly comparable without timezone arithmetic.
Expand Down Expand Up @@ -74,9 +92,11 @@ def validate_file(path: Path) -> tuple[str | None, list[str]]:
timestamp = doc["timestamp"]
author = doc["author"]

if not isinstance(cve, str) or not _CVE_RE.match(cve):
if not isinstance(cve, str) or not _is_accepted_id(cve):
errors.append(
f"{path}: 'cve' must match CVE-YYYY-NNNN+, got {cve!r}"
f"{path}: 'cve' must be a CVE-YYYY-NNNN+, "
f"GHSA-xxxx-xxxx-xxxx, or RUSTSEC-YYYY-NNNN identifier, "
f"got {cve!r}"
)

if status not in VALID_STATUSES:
Expand Down
Loading