diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c5cc5..0676a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: ci on: push: - branches: [dev] + branches: [dev, main] pull_request: - branches: [dev] + branches: [dev, main] permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cd7b2e..6474567 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,16 +32,13 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Validate stable tag and release ancestry + - name: Validate signed release tag and ancestry shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::release tags must be stable semantic versions such as v0.1.0" - exit 1 - fi + release_kind="$(hack/release-tag-classify.sh "$GITHUB_REF_NAME")" git fetch --no-tags origin main if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then echo "::error::release tag must point to a commit reachable from main" @@ -57,6 +54,7 @@ jobs: exit 1 fi echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + echo "RELEASE_KIND=${release_kind}" >> "$GITHUB_ENV" - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -161,4 +159,14 @@ jobs: - name: Publish completed release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release edit "$GITHUB_REF_NAME" --draft=false --latest + run: | + set -euo pipefail + if [[ "$RELEASE_KIND" == "beta" ]]; then + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${release_id}" \ + -F draft=false \ + -F prerelease=true \ + -f make_latest=false >/dev/null + else + gh release edit "$GITHUB_REF_NAME" --draft=false --latest + fi diff --git a/Makefile b/Makefile index e56f921..a1494af 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,9 @@ test: ## Run unit tests with the race detector and report coverage test-scripts: ## Run focused safety tests for operator-facing shell harnesses bash tests/scripts/m0_ocm_falsification_safety_test.sh + bash tests/scripts/release_tag_identity_guide_test.sh + bash tests/scripts/release_tag_policy_test.sh + bash tests/scripts/release_pr_gate_policy_test.sh perf: ## Enforce the warm-cache TUI p95 latency budget without race overhead go test -count=1 -run '^TestWarmViewP95UnderOneHundredMilliseconds$$' ./internal/tui diff --git a/README.md b/README.md index 47b631a..e4e370c 100644 --- a/README.md +++ b/README.md @@ -157,15 +157,18 @@ not accepted as Sith proofs. The Phase-1 read-federation foundation persists a tenant-scoped, bounded snapshot from each registered OCM spoke through the same normalized fleet model used locally. A transport receives only the workspace boundary and registered managed-cluster reference; it never receives a raw -kubeconfig, endpoint, or token through the Sith collector contract. Only normalized `inventory` -and `health` facts are accepted, source-stamped, freshness-bounded, and stored behind forced RLS. +kubeconfig, endpoint, or token through the Sith collector contract. Only normalized `inventory`, +`health`, and bounded immutable-image `cve` facts are accepted, source-stamped, +freshness-bounded, and stored behind forced RLS. Failed refreshes retain the last snapshot as explicitly stale evidence and record only a closed failure category. The pinned direct OCM ClusterProxy adapter reads the exact rotating `sith-reader` managed-serviceaccount Secret for a registered spoke, opens a short-lived Konnectivity tunnel only to that spoke, and verifies both proxy mTLS and the spoke Kubernetes certificate; it never forwards a caller `Authorization` header, stores a credential, disables TLS verification, lists or watches Secrets, or carries raw Kubernetes objects across the -collector seam. Its executable two-spoke M0 gate is `make e2e-ocm`, which now also drives a +collector seam. Its fixed read surface is Pods, Deployments, Rollouts, and optional +`aquasecurity.github.io/v1alpha1` `VulnerabilityReport` resources; it never discovers arbitrary +CRDs. Its executable two-spoke M0 gate is `make e2e-ocm`, which now also drives a signed-session request through the TLS hub runtime across both spokes. The same model now answers a read-only, exact cross-cluster correlation such as “every deployment named `payments` that is not Healthy” within one workspace. Matching is by exact kind/name/namespace @@ -193,7 +196,9 @@ mount. The runtime obtains its Kubernetes identity only with in-cluster configur kubeconfig fallback and uses that identity through the fixed `sith-reader` Secret reader. It serves only `POST /v1/workspaces/{workspace}/fleet:refresh`, `GET /v1/workspaces/{workspace}/fleet`, and -`GET /v1/workspaces/{workspace}/fleet/images/{sha256:<64-lowercase-hex>}`. Every route requires an +`GET /v1/workspaces/{workspace}/fleet/images/{sha256:<64-lowercase-hex>}`, and +`GET /v1/workspaces/{workspace}/fleet/images/{sha256:<64-lowercase-hex>}/cves`, and +`GET /v1/workspaces/{workspace}/fleet/cves/{CVE-YYYY-N...}`. Every route requires an exact signed Sith session, derives the workspace scope from its signed memberships, carries that scope through the PEP and RLS seams, accepts no query parameters, and returns only normalized coverage/fleet data under `Cache-Control: no-store`. @@ -220,6 +225,24 @@ request, image pull, SBOM retrieval, vulnerability-feed lookup, or credential us The result remains coverage-honest: matching Pod inventory facts retain source and freshness, and unreachable or stale spokes are reported rather than assumed clean. +The CVE route answers the narrower question “which already-reported CVE facts match this exact +runtime-proven digest?” The direct reader uses only the optional, fixed Kubernetes +`aquasecurity.github.io/v1alpha1` `VulnerabilityReport` resource and accepts a report only when +its canonical artifact digest matches an ordinary Pod-status digest from the same snapshot. It +retains only that digest, sorted CVE IDs, and the highest normalized severity; raw report content, +package data, descriptions, links, scanner metadata, registry values, and workload metadata are +discarded. Sith does not install or execute a scanner, pull an image, request an SBOM, query a +registry or vulnerability feed, or use a new credential. A missing report CRD yields no positive +CVE fact, not a clean-image claim; any other report-list failure makes the existing snapshot stale +and unreachable under the same coverage contract. + +The inverse CVE route accepts one exact, canonical upper-case CVE identifier only—no case +normalization, lists, globs, severity filters, or arbitrary JSON selectors. It returns the same +bounded normalized image facts and coverage metadata as the image route, scoped through the +signed workspace membership, PEP, and forced-RLS query. An empty result is only an absence of +currently reported runtime-proven evidence; it is never a claim that the workspace or fleet is +free of that CVE. + ### Hub schema migration Run `sith hub migrate` as a short-lived deployment Job before starting `sith hub`. It accepts only diff --git a/docs/RELEASE.md b/docs/RELEASE.md index af3260c..f98f9b2 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,6 +1,7 @@ # Sith release and verification guide -Sith releases are immutable, tag-driven builds from `main`. The release job creates a draft, +Sith releases are immutable, tag-driven builds from `main`. Stable releases use `vMAJOR.MINOR.PATCH`; +the beta channel uses `vMAJOR.MINOR.PATCH-beta.N` and never replaces the latest stable release. The release job creates a draft, builds four archives with GoReleaser, emits an SPDX 2.3 SBOM for each archive with Syft, signs the archives, SBOMs, and checksum manifest with keyless Cosign, and creates GitHub SLSA provenance plus one SBOM attestation per platform. The draft becomes public only after every step succeeds. @@ -109,19 +110,76 @@ slice is not a claim of the parent feature's future in-chart database, HA, or cl ## Maintainer release procedure 1. Merge the feature PR into `dev`, ensure the full CI and release-snapshot jobs are green, then - merge a reviewed `dev` to `main` release PR. `dev` is the durable integration source: never use - `--delete-branch` for this release PR. Automatic branch deletion is reserved for merged feature - branches. + open a `dev` to `main` release PR. The same full CI and release-snapshot jobs must be green on + that release PR before merging it, and the exact `main` push run must pass afterward. `dev` is + the durable integration source: never use `--delete-branch` for this release PR. Automatic + branch deletion is reserved for merged feature branches. 2. From an up-to-date `main`, run `make ci` and `make release-check`. The latter compares archive SHA-256 digests across two complete builds; SBOM creation timestamps and Sigstore signatures are intentionally not expected to be byte-for-byte reproducible. -3. Create an annotated, signed stable-semver tag on the release commit and push only that tag. -4. Watch the `release` workflow. A failure leaves a draft, not a partially trusted public release. +3. Verify the configured tagger identity before creating a release tag. Local `git tag -v` proves + that a signature is cryptographically valid on this machine; it does not prove that GitHub can + associate the SSH signing key and tagger identity with an account. The release workflow requires + GitHub verification, so this is a fail-closed preflight, not an optional cosmetic check. The + signing key must be registered with the intended GitHub account and the configured tagger email + must be one GitHub recognizes for that account. The account's verified no-reply address is an + appropriate choice when its public email is unavailable. + + ```bash + tagger_email="$(git config user.email)" + test -n "$tagger_email" + test "$(git config gpg.format)" = ssh + signing_key_file="$(git config user.signingkey)" + test -f "$signing_key_file" + signing_key="$(awk '{print $1 " " $2}' "$signing_key_file")" + verified_emails="$(gh api user/emails --paginate \ + --jq '.[] | select(.verified) | .email')" + grep -Fxq -- "$tagger_email" <<<"$verified_emails" + github_login="$(gh api user --jq '.login')" + registered_signing_keys="$(gh api "users/${github_login}/ssh_signing_keys" \ + --paginate --jq '.[].key' | awk '{print $1 " " $2}')" + grep -Fxq -- "$signing_key" <<<"$registered_signing_keys" + ``` + + The `user/emails` call intentionally reads only the authenticated maintainer's local account + metadata, while the signing-key comparison uses only the account's public signing keys and + ignores optional key comments. Neither command prints key or email material; do not paste their + values into issues, logs, or journals. If either command cannot run or a comparison does not + match, resolve the account identity before creating a tag. See + GitHub's [signature-verification overview](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) + and [tag-signing guide](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-tags). +4. Create an annotated, SSH-signed canonical release tag on the release commit and verify it + locally, then push only that tag. Stable tags use `vMAJOR.MINOR.PATCH`; beta tags use exactly + `vMAJOR.MINOR.PATCH-beta.N` with a numeric `N`. The beta workflow publishes a prerelease with + the same archives, SBOMs, signatures, and attestations, but never replaces the latest stable + release: + + ```bash + tag=vX.Y.Z # or vX.Y.Z-beta.N + git tag -s -a "$tag" -m "release: $tag" + git tag -v "$tag" + git push origin "refs/tags/$tag" + ``` + + After the push, confirm GitHub's tag-object verdict while the release workflow is running. This + distinguishes local signature validity from the verification that the release gate enforces: + + ```bash + tag_object=$(gh api "repos/ArdurAI/sith/git/ref/tags/${tag}" --jq '.object.sha') + test "$(gh api "repos/ArdurAI/sith/git/tags/${tag_object}" \ + --jq '.verification.verified')" = true + ``` + + If this check fails, do not delete, force-push, or retag the published name. Diagnose the + reported verification reason and cut a new patch version only after the identity issue is fixed. + GitHub exposes the status and reason for signed tags in its + [verification-status guidance](https://docs.github.com/en/authentication/troubleshooting-commit-signature-verification/checking-your-commit-and-tag-signature-verification-status). +5. Watch the `release` workflow. A failure leaves a draft, not a partially trusted public release. A rerun replaces the incomplete draft and its assets. -5. Verify one archive with the commands above, dispatch the `ArdurAI/homebrew-tap` sync workflow, +6. Verify one archive with the commands above, dispatch the `ArdurAI/homebrew-tap` sync workflow, and prove a clean `brew install sith && sith version` before announcing the release. -6. Check Dependabot, code-scanning, and secret-scanning alerts after publication. -7. Confirm `dev` still exists at the intended integration tip before starting the next feature +7. Check Dependabot, code-scanning, and secret-scanning alerts after publication. +8. Confirm `dev` still exists at the intended integration tip before starting the next feature branch. Published versions are immutable. A bad public release is corrected with a new patch version; do diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b84a9b0..8ddee20 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,9 +43,11 @@ transport/agent scope is deleted; the hub track proceeds to Phase 1. > ClusterProxy Konnectivity client directly, the exact rotating `sith-reader` MSA projection, and > a fixed registered managed-cluster target. The adapter does not forward caller authorization, > disables neither proxy nor Kubernetes TLS verification, and returns only normalized -> Pods/Deployments/Rollouts inventory plus health. `make e2e-ocm` proves the direct route across +> Pods/Deployments/Rollouts inventory plus health and, where already present, bounded +> runtime-proven `VulnerabilityReport` CVE facts. `make e2e-ocm` proves the direct route across > both M0 spokes, its `403` Secrets negative control, an MSA projection replacement, and the -> authenticated TLS runtime refresh/read composition. This does not unblock [#103](https://github.com/ArdurAI/sith/issues/103); that ClusterGateway-specific +> authenticated TLS runtime refresh/read composition and exact runtime-proven image/CVE queries. +> This does not unblock [#103](https://github.com/ArdurAI/sith/issues/103); that ClusterGateway-specific > transport remains blocked by [#104](https://github.com/ArdurAI/sith/issues/104) pending an > official upstream release. diff --git a/hack/experiments/m0-ocm-falsification.sh b/hack/experiments/m0-ocm-falsification.sh index bdbccfb..10ce9dc 100755 --- a/hack/experiments/m0-ocm-falsification.sh +++ b/hack/experiments/m0-ocm-falsification.sh @@ -607,6 +607,69 @@ EOF --timeout=180s } +install_vulnerability_report_fixture() { + local context=$1 + local image_id + local digest + image_id="$(${KUBECTL_BIN} --context "${context}" -n sith-demo get pod -l app=fixture \ + -o jsonpath='{.items[0].status.containerStatuses[0].imageID}')" + if [[ ! "${image_id}" =~ (sha256:[a-f0-9]{64}) ]]; then + die "fixture runtime image ID did not contain one canonical digest" + fi + digest="${BASH_REMATCH[1]}" + + # The fixture is a static, pre-existing Kubernetes-native report. It deliberately does not + # install or execute a scanner, and its CRD preserves only the report shape exercised by Sith. + "${KUBECTL_BIN}" --context "${context}" apply -f - <<'EOF' +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: vulnerabilityreports.aquasecurity.github.io +spec: + group: aquasecurity.github.io + names: + kind: VulnerabilityReport + listKind: VulnerabilityReportList + plural: vulnerabilityreports + singular: vulnerabilityreport + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true +EOF + "${KUBECTL_BIN}" --context "${context}" wait --for=condition=Established \ + customresourcedefinition/vulnerabilityreports.aquasecurity.github.io --timeout=60s + "${KUBECTL_BIN}" --context "${context}" apply -f - <" >&2 + exit 2 +fi + +tag="$1" +numeric='(0|[1-9][0-9]*)' + +if [[ "$tag" =~ ^v${numeric}\.${numeric}\.${numeric}$ ]]; then + printf 'stable\n' + exit 0 +fi + +if [[ "$tag" =~ ^v${numeric}\.${numeric}\.${numeric}-beta\.${numeric}$ ]]; then + printf 'beta\n' + exit 0 +fi + +echo "release tag must be canonical vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-beta.N" >&2 +exit 1 diff --git a/internal/fleet/coverage_test.go b/internal/fleet/coverage_test.go new file mode 100644 index 0000000..bb32234 --- /dev/null +++ b/internal/fleet/coverage_test.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import ( + "slices" + "testing" +) + +func TestCoverageAssessment(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in Coverage + want CoverageAssessment + }{ + { + name: "fresh complete fleet", + in: Coverage{Requested: 2, Reachable: 2}, + want: CoverageAssessment{Complete: true}, + }, + { + name: "empty requested set is complete evidence but not an authorization decision", + in: Coverage{}, + want: CoverageAssessment{Complete: true}, + }, + { + name: "stale reachable scope is incomplete", + in: Coverage{Requested: 2, Reachable: 2, Stale: []string{"spoke-b"}}, + want: CoverageAssessment{ + Gaps: []CoverageGap{CoverageGapStale}, + Stale: []string{"spoke-b"}, + }, + }, + { + name: "unreachable scope is incomplete", + in: Coverage{Requested: 2, Reachable: 1, Unreachable: []string{"spoke-b"}}, + want: CoverageAssessment{ + Gaps: []CoverageGap{CoverageGapUnreachable}, + Unreachable: []string{"spoke-b"}, + }, + }, + { + name: "stale and unreachable scope is incomplete", + in: Coverage{ + Requested: 2, Reachable: 1, Unreachable: []string{"spoke-b"}, Stale: []string{"spoke-b"}, + }, + want: CoverageAssessment{ + Gaps: []CoverageGap{CoverageGapUnreachable, CoverageGapStale}, + Unreachable: []string{"spoke-b"}, + Stale: []string{"spoke-b"}, + }, + }, + { + name: "unaccounted requested scope is incomplete", + in: Coverage{Requested: 3, Reachable: 1, Unreachable: []string{"spoke-b"}}, + want: CoverageAssessment{ + Gaps: []CoverageGap{CoverageGapUnreachable, CoverageGapUnaccounted}, + Unreachable: []string{"spoke-b"}, + Unaccounted: 1, + }, + }, + { + name: "contradictory counters are incomplete", + in: Coverage{Requested: 1, Reachable: 1, Unreachable: []string{"spoke-b"}}, + want: CoverageAssessment{ + Gaps: []CoverageGap{CoverageGapInconsistent, CoverageGapUnreachable}, + Unreachable: []string{"spoke-b"}, + Inconsistent: true, + }, + }, + { + name: "more stale names than requested scopes is inconsistent", + in: Coverage{Requested: 1, Reachable: 1, Stale: []string{"spoke-a", "spoke-b"}}, + want: CoverageAssessment{ + Gaps: []CoverageGap{CoverageGapInconsistent, CoverageGapStale}, + Stale: []string{"spoke-a", "spoke-b"}, + Inconsistent: true, + }, + }, + { + name: "duplicate and blank scope names are inconsistent", + in: Coverage{Requested: 2, Reachable: 0, Unreachable: []string{"spoke-b", "spoke-b"}, Stale: []string{" "}}, + want: CoverageAssessment{ + Gaps: []CoverageGap{CoverageGapInconsistent, CoverageGapUnreachable, CoverageGapStale}, + Unreachable: []string{"spoke-b", "spoke-b"}, + Stale: []string{" "}, + Inconsistent: true, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got := test.in.Assessment() + if got.Complete != test.want.Complete || got.Unaccounted != test.want.Unaccounted || got.Inconsistent != test.want.Inconsistent || + !slices.Equal(got.Gaps, test.want.Gaps) || !slices.Equal(got.Unreachable, test.want.Unreachable) || !slices.Equal(got.Stale, test.want.Stale) { + t.Fatalf("Assessment() = %#v, want %#v", got, test.want) + } + if complete := test.in.Complete(); complete != test.want.Complete { + t.Fatalf("Complete() = %t, want %t", complete, test.want.Complete) + } + }) + } +} + +func TestCoverageAssessmentDoesNotAliasInputSlices(t *testing.T) { + t.Parallel() + + coverage := Coverage{Requested: 2, Reachable: 1, Unreachable: []string{"spoke-b"}, Stale: []string{"spoke-b"}} + assessment := coverage.Assessment() + assessment.Unreachable[0] = "mutated" + assessment.Stale[0] = "mutated" + + if !slices.Equal(coverage.Unreachable, []string{"spoke-b"}) || !slices.Equal(coverage.Stale, []string{"spoke-b"}) { + t.Fatalf("Assessment() aliased Coverage input: %#v", coverage) + } +} diff --git a/internal/fleet/cve.go b/internal/fleet/cve.go new file mode 100644 index 0000000..3fef1c2 --- /dev/null +++ b/internal/fleet/cve.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import ( + "fmt" + "sort" + "strings" +) + +const maxCVEIdentifiers = 256 + +// NormalizeCVEIdentifier returns one canonical CVE identifier. +func NormalizeCVEIdentifier(value string) (string, error) { + if strings.TrimSpace(value) != value { + return "", fmt.Errorf("CVE identifier must not contain surrounding whitespace") + } + value = strings.ToUpper(value) + if len(value) < len("CVE-0000-0000") || !strings.HasPrefix(value, "CVE-") || value[8] != '-' { + return "", fmt.Errorf("CVE identifier has an invalid format") + } + for _, character := range value[4:8] { + if character < '0' || character > '9' { + return "", fmt.Errorf("CVE identifier has an invalid year") + } + } + for _, character := range value[9:] { + if character < '0' || character > '9' { + return "", fmt.Errorf("CVE identifier has an invalid sequence") + } + } + return value, nil +} + +// NormalizeCVESeverity converts the fixed Kubernetes report severity vocabulary to Sith's +// lower-case response profile. +func NormalizeCVESeverity(value string) (string, error) { + if strings.TrimSpace(value) != value { + return "", fmt.Errorf("CVE severity must not contain surrounding whitespace") + } + switch strings.ToUpper(value) { + case "CRITICAL": + return "critical", nil + case "HIGH": + return "high", nil + case "MEDIUM": + return "medium", nil + case "LOW": + return "low", nil + case "UNKNOWN": + return "unknown", nil + default: + return "", fmt.Errorf("CVE severity is unsupported") + } +} + +// ValidateCVEObservation validates the privacy-preserving normalized vulnerability profile. +func ValidateCVEObservation(observation CVEObservation) error { + if err := ValidateImageDigest(observation.Image); err != nil { + return fmt.Errorf("CVE image digest: %w", err) + } + if len(observation.IDs) == 0 || len(observation.IDs) > maxCVEIdentifiers { + return fmt.Errorf("CVE identifiers must contain between 1 and %d entries", maxCVEIdentifiers) + } + previous := "" + for _, identifier := range observation.IDs { + canonical, err := NormalizeCVEIdentifier(identifier) + if err != nil || canonical != identifier { + return fmt.Errorf("CVE identifier %q is not canonical", identifier) + } + if previous != "" && previous >= identifier { + return fmt.Errorf("CVE identifiers must be unique and sorted") + } + previous = identifier + } + if _, err := NormalizeCVESeverity(observation.Severity); err != nil || observation.Severity != strings.ToLower(observation.Severity) { + return fmt.Errorf("CVE severity is not canonical") + } + return nil +} + +// CanonicalCVEObservation returns the bounded canonical image observation assembled from +// untrusted scanner-reported identifiers and severities. +func CanonicalCVEObservation(image string, identifiers []string, severity string) (CVEObservation, error) { + if err := ValidateImageDigest(image); err != nil { + return CVEObservation{}, fmt.Errorf("CVE image digest: %w", err) + } + if len(identifiers) == 0 || len(identifiers) > maxCVEIdentifiers { + return CVEObservation{}, fmt.Errorf("CVE identifiers must contain between 1 and %d entries", maxCVEIdentifiers) + } + seen := make(map[string]struct{}, len(identifiers)) + canonical := make([]string, 0, len(identifiers)) + for _, identifier := range identifiers { + value, err := NormalizeCVEIdentifier(identifier) + if err != nil { + return CVEObservation{}, err + } + if _, exists := seen[value]; exists { + return CVEObservation{}, fmt.Errorf("CVE identifier %q is duplicated", value) + } + seen[value] = struct{}{} + canonical = append(canonical, value) + } + canonicalSeverity, err := NormalizeCVESeverity(severity) + if err != nil { + return CVEObservation{}, err + } + sort.Strings(canonical) + return CVEObservation{Image: image, IDs: canonical, Severity: canonicalSeverity}, nil +} diff --git a/internal/fleet/cve_test.go b/internal/fleet/cve_test.go new file mode 100644 index 0000000..d1ad199 --- /dev/null +++ b/internal/fleet/cve_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import ( + "strings" + "testing" +) + +func TestCanonicalCVEObservationNormalizesBoundedInput(t *testing.T) { + t.Parallel() + + digest := "sha256:" + strings.Repeat("a", 64) + observation, err := CanonicalCVEObservation(digest, []string{"cve-2026-9999", "CVE-2020-0001"}, "HIGH") + if err != nil || observation.Image != digest || observation.Severity != "high" || + !equalStrings(observation.IDs, []string{"CVE-2020-0001", "CVE-2026-9999"}) { + t.Fatalf("CanonicalCVEObservation() = %#v, %v", observation, err) + } + if err := ValidateCVEObservation(observation); err != nil { + t.Fatalf("ValidateCVEObservation() error = %v", err) + } +} + +func TestCanonicalCVEObservationRejectsAmbiguousInput(t *testing.T) { + t.Parallel() + + digest := "sha256:" + strings.Repeat("a", 64) + for _, input := range []struct { + identifiers []string + severity string + }{ + {identifiers: []string{"CVE-2026-1"}, severity: "HIGH"}, + {identifiers: []string{"CVE-2026-0001", "cve-2026-0001"}, severity: "HIGH"}, + {identifiers: []string{"CVE-2026-0001"}, severity: "urgent"}, + } { + if _, err := CanonicalCVEObservation(digest, input.identifiers, input.severity); err == nil { + t.Fatalf("CanonicalCVEObservation(%#v) unexpectedly succeeded", input) + } + } +} + +func TestValidateCVEObservationRejectsNonCanonicalInput(t *testing.T) { + t.Parallel() + + digest := "sha256:" + strings.Repeat("a", 64) + for _, observation := range []CVEObservation{ + {Image: "registry.example/api:latest", IDs: []string{"CVE-2026-0001"}, Severity: "high"}, + {Image: digest, IDs: []string{"CVE-2026-0001", "CVE-2026-0001"}, Severity: "high"}, + {Image: digest, IDs: []string{"CVE-2026-0001"}, Severity: "HIGH"}, + } { + if err := ValidateCVEObservation(observation); err == nil { + t.Fatalf("ValidateCVEObservation(%#v) unexpectedly succeeded", observation) + } + } +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/internal/fleet/model.go b/internal/fleet/model.go index d1a6be1..5af1887 100644 --- a/internal/fleet/model.go +++ b/internal/fleet/model.go @@ -3,7 +3,11 @@ // Package fleet defines the source-abstract fleet snapshot consumed by Sith surfaces. package fleet -import "time" +import ( + "sort" + "strings" + "time" +) // FleetResult is the normalized snapshot returned by a Source. // @@ -30,7 +34,84 @@ type Coverage struct { Stale []string `json:"stale,omitempty"` } -// Complete reports whether every requested scope answered with fresh data. +// CoverageGap explains why coverage is not safe to treat as complete. +type CoverageGap string + +// Closed coverage gaps available to future abstention logic. +const ( + CoverageGapInconsistent CoverageGap = "inconsistent" + CoverageGapUnreachable CoverageGap = "unreachable" + CoverageGapStale CoverageGap = "stale" + CoverageGapUnaccounted CoverageGap = "unaccounted" +) + +// CoverageAssessment is a defensive, deterministic explanation of coverage completeness. +// It contains no authorization decision; a future policy layer must still validate its target set. +type CoverageAssessment struct { + Complete bool `json:"complete"` + Gaps []CoverageGap `json:"gaps,omitempty"` + Unreachable []string `json:"unreachable,omitempty"` + Stale []string `json:"stale,omitempty"` + Unaccounted int `json:"unaccounted,omitempty"` + Inconsistent bool `json:"inconsistent"` +} + +// Assessment derives a fail-closed completeness result from the existing coverage fields. +// The returned slices are sorted copies and never alias Coverage's input slices. +func (c Coverage) Assessment() CoverageAssessment { + unreachable, unreachableValid := coverageScopes(c.Unreachable) + stale, staleValid := coverageScopes(c.Stale) + + assessment := CoverageAssessment{ + Unreachable: unreachable, + Stale: stale, + } + if c.Requested < 0 || c.Reachable < 0 || c.Reachable > c.Requested || len(stale) > c.Requested || + !unreachableValid || !staleValid { + assessment.Inconsistent = true + } else { + remaining := c.Requested - c.Reachable + switch { + case len(unreachable) > remaining: + assessment.Inconsistent = true + case len(unreachable) < remaining: + assessment.Unaccounted = remaining - len(unreachable) + } + } + + if assessment.Inconsistent { + assessment.Gaps = append(assessment.Gaps, CoverageGapInconsistent) + } + if len(assessment.Unreachable) != 0 { + assessment.Gaps = append(assessment.Gaps, CoverageGapUnreachable) + } + if len(assessment.Stale) != 0 { + assessment.Gaps = append(assessment.Gaps, CoverageGapStale) + } + if assessment.Unaccounted != 0 { + assessment.Gaps = append(assessment.Gaps, CoverageGapUnaccounted) + } + assessment.Complete = len(assessment.Gaps) == 0 + return assessment +} + +// Complete reports whether the coverage is internally consistent and every requested scope answered with fresh data. func (c Coverage) Complete() bool { - return c.Requested == c.Reachable && len(c.Stale) == 0 + return c.Assessment().Complete +} + +func coverageScopes(values []string) ([]string, bool) { + if len(values) == 0 { + return nil, true + } + + cloned := append([]string(nil), values...) + sort.Strings(cloned) + valid := true + for index, value := range cloned { + if strings.TrimSpace(value) == "" || (index != 0 && value == cloned[index-1]) { + valid = false + } + } + return cloned, valid } diff --git a/internal/hubdb/fleet.go b/internal/hubdb/fleet.go index 4d84032..2bd6928 100644 --- a/internal/hubdb/fleet.go +++ b/internal/hubdb/fleet.go @@ -69,7 +69,7 @@ func (database *AppDB) RegisteredSpokes(ctx context.Context, scope tenancy.Scope return spokes, nil } -// ReplaceSnapshot atomically replaces one registered spoke's normalized inventory and health facts. +// ReplaceSnapshot atomically replaces one registered spoke's normalized inventory, health, and CVE facts. func (database *AppDB) ReplaceSnapshot( ctx context.Context, scope tenancy.Scope, @@ -204,7 +204,7 @@ func (database *AppDB) ReadFleet( return fleet.FleetResult{Clusters: clusters, Coverage: coverage}, nil } -// QueryFleet returns only the active workspace's normalized inventory and health facts. +// QueryFleet returns only the active workspace's normalized inventory, health, and CVE facts. func (database *AppDB) QueryFleet( ctx context.Context, scope tenancy.Scope, @@ -333,7 +333,15 @@ func queryFacts(ctx context.Context, tx pgx.Tx, workspaceID tenancy.WorkspaceID, conditions = append(conditions, "fact.payload->>'status' <> "+placeholder(query.Selector.HealthNot)) } if query.Selector.Image != "" { - conditions = append(conditions, "fact.payload->'image_digests' ? "+placeholder(query.Selector.Image)) + switch query.Kinds[0] { + case fleet.FactInventory: + conditions = append(conditions, "fact.payload->'image_digests' ? "+placeholder(query.Selector.Image)) + case fleet.FactCVE: + conditions = append(conditions, "fact.payload->>'image' = "+placeholder(query.Selector.Image)) + } + } + if query.Selector.CVE != "" { + conditions = append(conditions, "(fact.payload -> 'ids') ? "+placeholder(query.Selector.CVE)) } limit := query.Limit if limit == 0 { @@ -425,21 +433,42 @@ func normalizeFleetQuery(query fleet.Query) (fleet.Query, []string, error) { return fleet.Query{}, nil, err } for _, kind := range query.Kinds { - if kind != fleet.FactInventory && kind != fleet.FactHealth { + if kind != fleet.FactInventory && kind != fleet.FactHealth && kind != fleet.FactCVE { return fleet.Query{}, nil, fmt.Errorf("fact kind %q is not available from persisted spoke snapshots", kind) } } - if len(query.Selector.Labels) != 0 || query.Selector.CVE != "" { + if len(query.Selector.Labels) != 0 { return fleet.Query{}, nil, fmt.Errorf("requested selector is not available from persisted spoke snapshots") } - if query.Selector.Image != "" { + if query.Selector.CVE != "" { + canonical, err := fleet.NormalizeCVEIdentifier(query.Selector.CVE) + if err != nil || canonical != query.Selector.CVE { + return fleet.Query{}, nil, fmt.Errorf("CVE selector must be one canonical identifier") + } + if len(query.Kinds) != 1 || query.Kinds[0] != fleet.FactCVE || query.Selector.ResourceKind != "Image" || + query.Selector.Image != "" || query.Selector.Namespace != "" || query.Selector.Name != "" || query.Selector.NamePrefix != "" || + query.Selector.Health != "" || query.Selector.HealthNot != "" { + return fleet.Query{}, nil, fmt.Errorf("CVE selector requires one exact Image CVE fact kind") + } + } else if query.Selector.Image != "" { if err := fleet.ValidateImageDigest(query.Selector.Image); err != nil { return fleet.Query{}, nil, fmt.Errorf("image selector: %w", err) } - if len(query.Kinds) != 1 || query.Kinds[0] != fleet.FactInventory || query.Selector.ResourceKind != "Pod" || - query.Selector.Namespace != "" || query.Selector.Name != "" || query.Selector.NamePrefix != "" || + if len(query.Kinds) != 1 || query.Selector.Namespace != "" || query.Selector.Name != "" || query.Selector.NamePrefix != "" || query.Selector.Health != "" || query.Selector.HealthNot != "" { - return fleet.Query{}, nil, fmt.Errorf("image selector requires exactly the Pod inventory fact kind") + return fleet.Query{}, nil, fmt.Errorf("image selector requires one exact supported image fact kind") + } + switch query.Kinds[0] { + case fleet.FactInventory: + if query.Selector.ResourceKind != "Pod" { + return fleet.Query{}, nil, fmt.Errorf("inventory image selector requires the Pod resource kind") + } + case fleet.FactCVE: + if query.Selector.ResourceKind != "Image" { + return fleet.Query{}, nil, fmt.Errorf("CVE image selector requires the Image resource kind") + } + default: + return fleet.Query{}, nil, fmt.Errorf("image selector requires an inventory or CVE fact kind") } } else if (query.Selector.Health != "" || query.Selector.HealthNot != "") && (len(query.Kinds) != 1 || query.Kinds[0] != fleet.FactHealth) { diff --git a/internal/hubdb/fleet_query_test.go b/internal/hubdb/fleet_query_test.go index e740b99..8b26f63 100644 --- a/internal/hubdb/fleet_query_test.go +++ b/internal/hubdb/fleet_query_test.go @@ -29,6 +29,49 @@ func TestNormalizeFleetQueryAllowsOnlyExactPodImageInventory(t *testing.T) { } } +func TestNormalizeFleetQueryAllowsOnlyExactImageCVEFacts(t *testing.T) { + t.Parallel() + + digest := "sha256:" + strings.Repeat("a", 64) + query, scopes, err := normalizeFleetQuery(fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactCVE}, + Scopes: []string{"spoke-b", "spoke-a", "spoke-a"}, + Selector: fleet.Selector{ + ResourceKind: "Image", + Image: digest, + }, + }) + if err != nil { + t.Fatalf("normalize exact CVE image query: %v", err) + } + if query.Selector.Image != digest || len(scopes) != 2 || scopes[0] != "spoke-a" || scopes[1] != "spoke-b" { + t.Fatalf("normalized CVE image query = %#v, scopes = %#v", query, scopes) + } +} + +func TestNormalizeFleetQueryAllowsOnlyExactCanonicalCVEIdentifierFacts(t *testing.T) { + t.Parallel() + + query, scopes, err := normalizeFleetQuery(fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactCVE}, + Selector: fleet.Selector{ResourceKind: "Image", CVE: "CVE-2026-0001"}, + }) + if err != nil || len(scopes) != 0 || query.Selector.CVE != "CVE-2026-0001" { + t.Fatalf("normalize exact CVE identifier query = %#v, scopes = %#v, error = %v", query, scopes, err) + } + for _, invalid := range []fleet.Query{ + {Kinds: []fleet.FactKind{fleet.FactCVE}, Selector: fleet.Selector{ResourceKind: "Image", CVE: "cve-2026-0001"}}, + {Kinds: []fleet.FactKind{fleet.FactCVE}, Selector: fleet.Selector{ResourceKind: "Image", CVE: "CVE-2026-0001*"}}, + {Kinds: []fleet.FactKind{fleet.FactCVE}, Selector: fleet.Selector{ResourceKind: "Image", CVE: "CVE-2026-0001", Image: "sha256:" + strings.Repeat("a", 64)}}, + {Kinds: []fleet.FactKind{fleet.FactInventory}, Selector: fleet.Selector{ResourceKind: "Image", CVE: "CVE-2026-0001"}}, + {Kinds: []fleet.FactKind{fleet.FactCVE}, Selector: fleet.Selector{ResourceKind: "Pod", CVE: "CVE-2026-0001"}}, + } { + if _, _, err := normalizeFleetQuery(invalid); err == nil { + t.Fatalf("normalizeFleetQuery(%#v) succeeded", invalid) + } + } +} + func TestNormalizeFleetQueryRejectsBroadOrUnsafeImageSelectors(t *testing.T) { t.Parallel() @@ -40,6 +83,9 @@ func TestNormalizeFleetQueryRejectsBroadOrUnsafeImageSelectors(t *testing.T) { {Kinds: []fleet.FactKind{fleet.FactInventory}, Selector: fleet.Selector{ResourceKind: "Pod", Image: digest, NamePrefix: "payments"}}, {Kinds: []fleet.FactKind{fleet.FactInventory}, Selector: fleet.Selector{ResourceKind: "Pod", Image: digest, Labels: map[string]string{"app": "payments"}}}, {Kinds: []fleet.FactKind{fleet.FactInventory}, Selector: fleet.Selector{ResourceKind: "Pod", Image: digest, CVE: "CVE-2026-0001"}}, + {Kinds: []fleet.FactKind{fleet.FactCVE}, Selector: fleet.Selector{ResourceKind: "Pod", Image: digest}}, + {Kinds: []fleet.FactKind{fleet.FactCVE}, Selector: fleet.Selector{ResourceKind: "Image", Image: digest, Name: "payments"}}, + {Kinds: []fleet.FactKind{fleet.FactHealth}, Selector: fleet.Selector{ResourceKind: "Image", Image: digest}}, } { if _, _, err := normalizeFleetQuery(query); err == nil { t.Fatalf("normalizeFleetQuery(%#v) unexpectedly succeeded", query) diff --git a/internal/hubdb/migrations/0007_fleet_cve_images.sql b/internal/hubdb/migrations/0007_fleet_cve_images.sql new file mode 100644 index 0000000..1ea308f --- /dev/null +++ b/internal/hubdb/migrations/0007_fleet_cve_images.sql @@ -0,0 +1,7 @@ +-- SPDX-License-Identifier: Apache-2.0 + +-- Exact immutable image CVE lookup is constrained to the normalized, report-derived image +-- payload. The index deliberately excludes raw report fields and all non-CVE facts. +CREATE INDEX fleet_facts_cve_image_idx + ON sith.fleet_facts (workspace_id, (payload ->> 'image'), cluster_id, observed_at DESC) + WHERE kind = 'cve'; diff --git a/internal/hubdb/migrations/0008_fleet_cve_identifiers.sql b/internal/hubdb/migrations/0008_fleet_cve_identifiers.sql new file mode 100644 index 0000000..83ca95a --- /dev/null +++ b/internal/hubdb/migrations/0008_fleet_cve_identifiers.sql @@ -0,0 +1,7 @@ +-- SPDX-License-Identifier: Apache-2.0 + +-- Exact CVE identifier lookup is restricted to the normalized identifier array on CVE facts. +-- The partial expression index cannot contain inventory or raw report data. +CREATE INDEX fleet_facts_cve_identifier_idx + ON sith.fleet_facts USING GIN ((payload -> 'ids')) + WHERE kind = 'cve'; diff --git a/internal/hubdb/postgres_integration_test.go b/internal/hubdb/postgres_integration_test.go index c822dee..4c25adb 100644 --- a/internal/hubdb/postgres_integration_test.go +++ b/internal/hubdb/postgres_integration_test.go @@ -560,6 +560,14 @@ func assertFleetStoreIntegration(t *testing.T, ctx context.Context, database *Ap Source: "cluster-a", Provenance: fleet.Provenance{Adapter: hubfleet.SourceKind, ProtocolV: "1.0.0"}, }, + { + Ref: fleet.ResourceRef{SourceKind: hubfleet.SourceKind, Scope: "cluster-a", Kind: "Image", Name: digest}, + Kind: fleet.FactCVE, + Observed: []byte(`{"image":"` + digest + `","ids":["CVE-2026-0001"],"severity":"high"}`), + ObservedAt: now, + Source: "cluster-a", + Provenance: fleet.Provenance{Adapter: hubfleet.SourceKind, ProtocolV: "1.0.0"}, + }, }} if err := database.ReplaceSnapshot(ctx, scope, spokes[0], snapshot, now); err != nil { t.Fatalf("replace workspace-a snapshot: %v", err) @@ -607,6 +615,14 @@ func assertFleetStoreIntegration(t *testing.T, ctx context.Context, database *Ap Source: "cluster-a2", Provenance: fleet.Provenance{Adapter: hubfleet.SourceKind, ProtocolV: "1.0.0"}, }, + { + Ref: fleet.ResourceRef{SourceKind: hubfleet.SourceKind, Scope: "cluster-a2", Kind: "Image", Name: digest}, + Kind: fleet.FactCVE, + Observed: []byte(`{"image":"` + digest + `","ids":["CVE-2026-0001","CVE-2026-0002"],"severity":"critical"}`), + ObservedAt: now, + Source: "cluster-a2", + Provenance: fleet.Provenance{Adapter: hubfleet.SourceKind, ProtocolV: "1.0.0"}, + }, }} if err := database.ReplaceSnapshot(ctx, scope, spokes[1], secondSnapshot, now); err != nil { t.Fatalf("replace second workspace-a snapshot: %v", err) @@ -617,6 +633,12 @@ func assertFleetStoreIntegration(t *testing.T, ctx context.Context, database *Ap if err != nil { t.Fatal(err) } + cveSearcher, err := hubfleet.NewCVESearcher(hubfleet.CVESearcherConfig{ + Querier: database, PEP: postgresReadPEP(t), Freshness: time.Minute, Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } imageSearcher, err := hubfleet.NewImageSearcher(hubfleet.ImageSearcherConfig{ Querier: database, PEP: postgresReadPEP(t), Freshness: time.Minute, Now: func() time.Time { return now }, }) @@ -628,6 +650,16 @@ func assertFleetStoreIntegration(t *testing.T, ctx context.Context, database *Ap images.Coverage.Requested != 2 || images.Coverage.Reachable != 2 || len(images.Coverage.Stale) != 0 { t.Fatalf("two-spoke exact image search = %#v, error = %v", images, err) } + cves, err := cveSearcher.Search(ctx, scope, hubfleet.ImageSearchRequest{Digest: digest}) + if err != nil || len(cves.Facts) != 2 || cves.Facts[0].Ref.Scope != "cluster-a" || cves.Facts[1].Ref.Scope != "cluster-a2" || + cves.Coverage.Requested != 2 || cves.Coverage.Reachable != 2 || len(cves.Coverage.Stale) != 0 { + t.Fatalf("two-spoke exact CVE search = %#v, error = %v", cves, err) + } + identifierCVEs, err := cveSearcher.SearchByIdentifier(ctx, scope, hubfleet.CVEIdentifierSearchRequest{Identifier: "CVE-2026-0001"}) + if err != nil || len(identifierCVEs.Facts) != 2 || identifierCVEs.Facts[0].Ref.Scope != "cluster-a" || identifierCVEs.Facts[1].Ref.Scope != "cluster-a2" || + identifierCVEs.Coverage.Requested != 2 || identifierCVEs.Coverage.Reachable != 2 || len(identifierCVEs.Coverage.Stale) != 0 { + t.Fatalf("two-spoke exact CVE identifier search = %#v, error = %v", identifierCVEs, err) + } correlated, err := correlator.Correlate(ctx, scope, hubfleet.CorrelationRequest{ ResourceKind: "Deployment", Name: "payments", Namespace: "payments", HealthNot: "Healthy", }) @@ -653,6 +685,14 @@ func assertFleetStoreIntegration(t *testing.T, ctx context.Context, database *Ap if err != nil || len(foreignImages.Facts) != 0 || foreignImages.Coverage.Requested != 1 || len(foreignImages.Coverage.Unreachable) != 1 { t.Fatalf("cross-workspace image search = %#v, error = %v", foreignImages, err) } + foreignCVEs, err := cveSearcher.Search(ctx, foreignScope, hubfleet.ImageSearchRequest{Digest: digest}) + if err != nil || len(foreignCVEs.Facts) != 0 || foreignCVEs.Coverage.Requested != 1 || len(foreignCVEs.Coverage.Unreachable) != 1 { + t.Fatalf("cross-workspace CVE search = %#v, error = %v", foreignCVEs, err) + } + foreignIdentifierCVEs, err := cveSearcher.SearchByIdentifier(ctx, foreignScope, hubfleet.CVEIdentifierSearchRequest{Identifier: "CVE-2026-0001"}) + if err != nil || len(foreignIdentifierCVEs.Facts) != 0 || foreignIdentifierCVEs.Coverage.Requested != 1 || len(foreignIdentifierCVEs.Coverage.Unreachable) != 1 { + t.Fatalf("cross-workspace CVE identifier search = %#v, error = %v", foreignIdentifierCVEs, err) + } foreignResult, err := database.QueryFleet(ctx, foreignScope, fleet.Query{Scopes: []string{"cluster-a"}}, time.Minute, now) if err != nil || len(foreignResult.Facts) != 0 || foreignResult.Coverage.Requested != 1 || len(foreignResult.Coverage.Unreachable) != 1 || foreignResult.Coverage.Unreachable[0] != "cluster-a" { @@ -685,7 +725,7 @@ func assertFleetStoreIntegration(t *testing.T, ctx context.Context, database *Ap t.Fatalf("stale two-spoke correlation = %#v, error = %v", staleCorrelation, err) } staleResult, err := database.QueryFleet(ctx, scope, fleet.Query{Scopes: []string{"cluster-a"}}, time.Minute, now.Add(time.Second)) - if err != nil || len(staleResult.Facts) != 4 || !staleResult.Facts[0].Stale || staleResult.Facts[0].StaleFor != "collection failed" || + if err != nil || len(staleResult.Facts) != 5 || !staleResult.Facts[0].Stale || staleResult.Facts[0].StaleFor != "collection failed" || staleResult.Coverage.Reachable != 0 || len(staleResult.Coverage.Unreachable) != 1 || len(staleResult.Coverage.Stale) != 1 { t.Fatalf("retained stale query = %#v, error = %v", staleResult, err) } diff --git a/internal/hubfleet/collector.go b/internal/hubfleet/collector.go index c1983e2..40e3ee5 100644 --- a/internal/hubfleet/collector.go +++ b/internal/hubfleet/collector.go @@ -30,7 +30,7 @@ const ( defaultSnapshotAge = 5 * time.Minute maxSpokeTimeout = 30 * time.Second maxSnapshotAge = time.Hour - maxSnapshotFacts = 1_000 + maxSnapshotFacts = 1_100 maxObservedBytes = 256 * 1024 maxFutureSkew = 30 * time.Second ) @@ -46,6 +46,7 @@ var observedKeys = map[fleet.FactKind]map[string]struct{}{ "image_digests": {}, }, fleet.FactHealth: {"status": {}}, + fleet.FactCVE: {"image": {}, "ids": {}, "severity": {}}, } // FailureKind is the closed, non-sensitive status persisted for a failed collection attempt. @@ -89,8 +90,9 @@ func (spoke Spoke) Validate() error { // Snapshot is one bounded normalized response from a registered spoke. // -// Facts are restricted to inventory and health. The transport may acquire a projected credential -// internally, but neither it nor endpoint material crosses this package boundary. +// Facts are restricted to inventory, health, and normalized CVE observations. The transport may +// acquire a projected credential internally, but neither it nor endpoint material crosses this +// package boundary. type Snapshot struct { ObservedAt time.Time `json:"observed_at"` Facts []fleet.Evidence `json:"facts"` @@ -330,7 +332,7 @@ func validateSnapshot(spoke Spoke, snapshot Snapshot, now time.Time, maximumAge } func validateEvidence(spoke Spoke, evidence fleet.Evidence, snapshotObservedAt, now time.Time, maximumAge time.Duration) error { - if evidence.Kind != fleet.FactInventory && evidence.Kind != fleet.FactHealth { + if evidence.Kind != fleet.FactInventory && evidence.Kind != fleet.FactHealth && evidence.Kind != fleet.FactCVE { return fmt.Errorf("fact kind %q is not allowed for a spoke snapshot", evidence.Kind) } if evidence.Ref.SourceKind != SourceKind || evidence.Ref.Scope != spoke.ID || evidence.Source != spoke.ID { @@ -370,6 +372,11 @@ func validateEvidence(spoke Spoke, evidence fleet.Evidence, snapshotObservedAt, return err } } + if evidence.Kind == fleet.FactCVE { + if err := validateCVEObservation(evidence); err != nil { + return err + } + } return validateDisplay(evidence.Display) } @@ -433,6 +440,23 @@ func validateInventoryImageDigests(evidence fleet.Evidence) error { return nil } +func validateCVEObservation(evidence fleet.Evidence) error { + if evidence.Ref.Kind != "Image" || evidence.Ref.Namespace != "" { + return fmt.Errorf("CVE observations must address one cluster-scoped Image") + } + var observation fleet.CVEObservation + if err := json.Unmarshal(evidence.Observed, &observation); err != nil { + return fmt.Errorf("decode normalized CVE observation: %w", err) + } + if err := fleet.ValidateCVEObservation(observation); err != nil { + return fmt.Errorf("normalized CVE observation: %w", err) + } + if evidence.Ref.Name != observation.Image { + return fmt.Errorf("CVE observation reference must name its immutable image digest") + } + return nil +} + func validateJSONObject(decoder *json.Decoder, allowed map[string]struct{}) error { seen := make(map[string]struct{}) for decoder.More() { diff --git a/internal/hubfleet/collector_test.go b/internal/hubfleet/collector_test.go index e8329be..3c7ddb4 100644 --- a/internal/hubfleet/collector_test.go +++ b/internal/hubfleet/collector_test.go @@ -132,6 +132,13 @@ func TestValidateSnapshotRejectsUnsafeOrAmbiguousEvidence(t *testing.T) { {name: "raw native identifier", snapshot: snapshotWith(func(fact *fleet.Evidence) { fact.Provenance.NativeID = "opaque-source-value" }, now)}, {name: "duplicate normalized fact", snapshot: duplicate}, {name: "old observation", snapshot: validSnapshot("spoke-a", now.Add(-6*time.Minute))}, + {name: "CVE wrong resource", snapshot: cveSnapshot("spoke-a", now, func(fact *fleet.Evidence) { fact.Ref.Kind = "Pod" })}, + {name: "CVE mutable image", snapshot: cveSnapshot("spoke-a", now, func(fact *fleet.Evidence) { + fact.Observed = json.RawMessage(`{"image":"registry.example/api:latest","ids":["CVE-2026-0001"],"severity":"high"}`) + })}, + {name: "CVE duplicate key", snapshot: cveSnapshot("spoke-a", now, func(fact *fleet.Evidence) { + fact.Observed = json.RawMessage(`{"image":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ids":["CVE-2026-0001"],"ids":["CVE-2026-0002"],"severity":"high"}`) + })}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -142,6 +149,16 @@ func TestValidateSnapshotRejectsUnsafeOrAmbiguousEvidence(t *testing.T) { } } +func TestValidateSnapshotAcceptsCanonicalCVEObservation(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.July, 14, 18, 0, 0, 0, time.UTC) + snapshot := cveSnapshot("spoke-a", now, nil) + if err := ValidateSnapshot(Spoke{ID: "spoke-a", ManagedClusterRef: "ocm/spoke-a"}, snapshot, now); err != nil { + t.Fatalf("ValidateSnapshot() error = %v", err) + } +} + func TestCollectorCopiesTransportOwnedSnapshot(t *testing.T) { t.Parallel() @@ -328,6 +345,22 @@ func validSnapshot(spokeID string, observedAt time.Time) Snapshot { }} } +func cveSnapshot(spokeID string, observedAt time.Time, change func(*fleet.Evidence)) Snapshot { + digest := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + fact := fleet.Evidence{ + Ref: fleet.ResourceRef{SourceKind: SourceKind, Scope: spokeID, Kind: "Image", Name: digest}, + Kind: fleet.FactCVE, + Observed: json.RawMessage(`{"image":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","ids":["CVE-2026-0001"],"severity":"high"}`), + ObservedAt: observedAt, + Source: spokeID, + Provenance: fleet.Provenance{Adapter: SourceKind, ProtocolV: protocolVersion}, + } + if change != nil { + change(&fact) + } + return Snapshot{ObservedAt: observedAt, Facts: []fleet.Evidence{fact}} +} + func snapshotWith(change func(*fleet.Evidence), observedAt time.Time) Snapshot { snapshot := validSnapshot("spoke-a", observedAt) change(&snapshot.Facts[0]) diff --git a/internal/hubfleet/cve_search.go b/internal/hubfleet/cve_search.go new file mode 100644 index 0000000..82c0483 --- /dev/null +++ b/internal/hubfleet/cve_search.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hubfleet + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/pep" + "github.com/ArdurAI/sith/internal/tenancy" + "github.com/ArdurAI/sith/internal/tracing" +) + +// CVESearcherConfig defines a read-only, tenant-scoped immutable image CVE evidence service. +type CVESearcherConfig struct { + Querier FleetQuerier + PEP *pep.Enforcer + Freshness time.Duration + Now func() time.Time +} + +// CVEIdentifierSearchRequest names one canonical CVE identifier across every registered spoke. +type CVEIdentifierSearchRequest struct { + Identifier string `json:"identifier"` + Limit int `json:"limit,omitempty"` +} + +// CVESearcher resolves normalized CVE facts for one exact immutable runtime image digest. +type CVESearcher struct { + querier FleetQuerier + pep *pep.Enforcer + freshness time.Duration + now func() time.Time +} + +// NewCVESearcher constructs a bounded read-only CVE evidence service. +func NewCVESearcher(config CVESearcherConfig) (*CVESearcher, error) { + if config.Querier == nil || config.PEP == nil { + return nil, fmt.Errorf("new fleet CVE searcher: querier and policy enforcer are required") + } + if config.Freshness == 0 { + config.Freshness = defaultSnapshotAge + } + if config.Freshness < time.Second || config.Freshness > maxSnapshotAge { + return nil, fmt.Errorf("new fleet CVE searcher: freshness must be between 1s and %s", maxSnapshotAge) + } + if config.Now == nil { + config.Now = time.Now + } + return &CVESearcher{querier: config.Querier, pep: config.PEP, freshness: config.Freshness, now: config.Now}, nil +} + +// Search returns coverage-honest reported CVE observations for one immutable runtime image. +func (searcher *CVESearcher) Search( + ctx context.Context, + scope tenancy.Scope, + request ImageSearchRequest, +) (fleet.QueryResult, error) { + if searcher == nil || searcher.querier == nil || searcher.pep == nil || ctx == nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVEs: searcher, policy enforcer, and context are required") + } + traceContext, _, err := tracing.Ensure(ctx) + if err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVEs: establish trace context: %w", err) + } + ctx = traceContext + if err := scope.Authorize(tenancy.ActionRead); err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVEs: %w", err) + } + if err := request.validate(); err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVEs: %w", err) + } + canonicalArguments := request.Digest + "\x00" + strconv.Itoa(request.Limit) + if err := searcher.pep.AuthorizeRead(ctx, scope, pep.NewReadInput(pep.VerbFleetCVESearch, []byte(canonicalArguments))); err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVEs: %w", err) + } + result, err := searcher.querier.QueryFleet(ctx, scope, fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactCVE}, + Selector: fleet.Selector{ + ResourceKind: "Image", + Image: request.Digest, + }, + Limit: request.Limit, + }, searcher.freshness, searcher.now().UTC()) + if err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVEs: %w", err) + } + return result, nil +} + +// SearchByIdentifier returns coverage-honest runtime-proven image observations for one exact CVE. +func (searcher *CVESearcher) SearchByIdentifier( + ctx context.Context, + scope tenancy.Scope, + request CVEIdentifierSearchRequest, +) (fleet.QueryResult, error) { + if searcher == nil || searcher.querier == nil || searcher.pep == nil || ctx == nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVE identifier: searcher, policy enforcer, and context are required") + } + traceContext, _, err := tracing.Ensure(ctx) + if err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVE identifier: establish trace context: %w", err) + } + ctx = traceContext + if err := scope.Authorize(tenancy.ActionRead); err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVE identifier: %w", err) + } + if err := request.validate(); err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVE identifier: %w", err) + } + canonicalArguments := request.Identifier + "\x00" + strconv.Itoa(request.Limit) + if err := searcher.pep.AuthorizeRead(ctx, scope, pep.NewReadInput(pep.VerbFleetCVEIdentifierSearch, []byte(canonicalArguments))); err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVE identifier: %w", err) + } + result, err := searcher.querier.QueryFleet(ctx, scope, fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactCVE}, + Selector: fleet.Selector{ + ResourceKind: "Image", + CVE: request.Identifier, + }, + Limit: request.Limit, + }, searcher.freshness, searcher.now().UTC()) + if err != nil { + return fleet.QueryResult{}, fmt.Errorf("search fleet CVE identifier: %w", err) + } + return result, nil +} + +func (request CVEIdentifierSearchRequest) validate() error { + canonical, err := fleet.NormalizeCVEIdentifier(request.Identifier) + if err != nil || canonical != request.Identifier { + return fmt.Errorf("CVE identifier must be canonical") + } + if request.Limit < 0 || request.Limit > 1_000 { + return fmt.Errorf("limit must be between 0 and 1000") + } + return nil +} diff --git a/internal/hubfleet/cve_search_test.go b/internal/hubfleet/cve_search_test.go new file mode 100644 index 0000000..dc28827 --- /dev/null +++ b/internal/hubfleet/cve_search_test.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hubfleet + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/pep" + "github.com/ArdurAI/sith/internal/tenancy" +) + +func TestCVESearcherUsesExactImageFactAndClosedPEPVerb(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.July, 14, 19, 0, 0, 0, time.UTC) + querier := &recordingFleetQuerier{result: fleet.QueryResult{Coverage: fleet.Coverage{Requested: 1, Reachable: 1}}} + searcher, err := NewCVESearcher(CVESearcherConfig{ + Querier: querier, PEP: testReadPEP(t), Freshness: time.Minute, Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatal(err) + } + digest := "sha256:" + strings.Repeat("a", 64) + result, err := searcher.Search(context.Background(), readerScope(t, "workspace-a"), ImageSearchRequest{Digest: digest, Limit: 12}) + if err != nil || result.Coverage.Reachable != 1 { + t.Fatalf("Search() result = %#v, error = %v", result, err) + } + if querier.scope.WorkspaceID() != "workspace-a" || len(querier.query.Kinds) != 1 || querier.query.Kinds[0] != fleet.FactCVE || + querier.query.Selector.ResourceKind != "Image" || querier.query.Selector.Image != digest || querier.query.Limit != 12 || + querier.freshness != time.Minute || !querier.now.Equal(now) { + t.Fatalf("CVE query = %#v, freshness = %s, now = %s", querier.query, querier.freshness, querier.now) + } +} + +func TestCVESearcherRejectsUntrustedArgumentsBeforeQuery(t *testing.T) { + t.Parallel() + + querier := &recordingFleetQuerier{} + searcher, err := NewCVESearcher(CVESearcherConfig{Querier: querier, PEP: testReadPEP(t)}) + if err != nil { + t.Fatal(err) + } + _, err = searcher.Search(context.Background(), readerScope(t, "workspace-a"), ImageSearchRequest{Digest: "registry.example/api:latest"}) + if err == nil || querier.calls != 0 { + t.Fatalf("Search() error/calls = %v/%d", err, querier.calls) + } +} + +func TestCVESearcherUsesDedicatedPEPVerb(t *testing.T) { + t.Parallel() + + var verb pep.Verb + enforcer, err := pep.NewEnforcer(pep.Config{ + Hook: pep.HookFunc(func(_ context.Context, request pep.Request) (pep.Decision, error) { + verb = request.Verb + return pep.Decision{Verdict: pep.VerdictAllow, ReasonCode: "allow"}, nil + }), + Auditor: pep.AuditFunc(func(context.Context, pep.AuditEvent) error { return nil }), + }) + if err != nil { + t.Fatal(err) + } + searcher, err := NewCVESearcher(CVESearcherConfig{Querier: fleetQuerierFunc(func(context.Context, tenancy.Scope, fleet.Query, time.Duration, time.Time) (fleet.QueryResult, error) { + return fleet.QueryResult{}, nil + }), PEP: enforcer}) + if err != nil { + t.Fatal(err) + } + _, err = searcher.Search(context.Background(), readerScope(t, "workspace-a"), ImageSearchRequest{Digest: "sha256:" + strings.Repeat("a", 64)}) + if err != nil || verb != pep.VerbFleetCVESearch { + t.Fatalf("Search() error/verb = %v/%q", err, verb) + } +} + +func TestCVESearcherSearchByIdentifierUsesExactFactAndDedicatedPEPVerb(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.July, 14, 20, 0, 0, 0, time.UTC) + querier := &recordingFleetQuerier{result: fleet.QueryResult{Coverage: fleet.Coverage{Requested: 2, Reachable: 2}}} + var verb pep.Verb + enforcer, err := pep.NewEnforcer(pep.Config{ + Hook: pep.HookFunc(func(_ context.Context, request pep.Request) (pep.Decision, error) { + verb = request.Verb + return pep.Decision{Verdict: pep.VerdictAllow, ReasonCode: "allow"}, nil + }), + Auditor: pep.AuditFunc(func(context.Context, pep.AuditEvent) error { return nil }), + }) + if err != nil { + t.Fatal(err) + } + searcher, err := NewCVESearcher(CVESearcherConfig{Querier: querier, PEP: enforcer, Freshness: time.Minute, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + result, err := searcher.SearchByIdentifier(context.Background(), readerScope(t, "workspace-a"), CVEIdentifierSearchRequest{Identifier: "CVE-2026-0001", Limit: 12}) + if err != nil || result.Coverage.Reachable != 2 { + t.Fatalf("SearchByIdentifier() result = %#v, error = %v", result, err) + } + if verb != pep.VerbFleetCVEIdentifierSearch || querier.scope.WorkspaceID() != "workspace-a" || len(querier.query.Kinds) != 1 || + querier.query.Kinds[0] != fleet.FactCVE || querier.query.Selector.ResourceKind != "Image" || querier.query.Selector.CVE != "CVE-2026-0001" || + querier.query.Selector.Image != "" || querier.query.Limit != 12 || querier.freshness != time.Minute || !querier.now.Equal(now) { + t.Fatalf("CVE identifier query = %#v, verb = %q, freshness = %s, now = %s", querier.query, verb, querier.freshness, querier.now) + } +} + +func TestCVESearcherSearchByIdentifierRejectsNonCanonicalInputBeforeQuery(t *testing.T) { + t.Parallel() + + querier := &recordingFleetQuerier{} + searcher, err := NewCVESearcher(CVESearcherConfig{Querier: querier, PEP: testReadPEP(t)}) + if err != nil { + t.Fatal(err) + } + for _, identifier := range []string{"cve-2026-0001", "CVE-2026-0001*", "CVE-2026-0001,CVE-2026-0002", " CVE-2026-0001"} { + if _, err := searcher.SearchByIdentifier(context.Background(), readerScope(t, "workspace-a"), CVEIdentifierSearchRequest{Identifier: identifier}); err == nil { + t.Fatalf("SearchByIdentifier(%q) succeeded", identifier) + } + } + if querier.calls != 0 { + t.Fatalf("SearchByIdentifier queried %d times", querier.calls) + } +} diff --git a/internal/hubocm/direct.go b/internal/hubocm/direct.go index 630197c..64c18cc 100644 --- a/internal/hubocm/direct.go +++ b/internal/hubocm/direct.go @@ -39,9 +39,17 @@ const ( protocolVersion = "1.0.0" maxResources = 500 listPageSize = 100 + + maxVulnerabilityReports = 100 + vulnerabilityReportPageSize = 10 + maxVulnerabilityReportPages = maxVulnerabilityReports / vulnerabilityReportPageSize + maxVulnerabilityReportBytes = 64 * 1024 ) -var rolloutGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "rollouts"} +var ( + rolloutGVR = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "rollouts"} + vulnerabilityReportGVR = schema.GroupVersionResource{Group: "aquasecurity.github.io", Version: "v1alpha1", Resource: "vulnerabilityreports"} +) // Config configures the direct ClusterProxy transport. ProxyTLSConfig must be constructed // from deployment-mounted proxy mTLS material; it is cloned and never persisted by Sith. @@ -232,6 +240,7 @@ type snapshotClient interface { ListDeployments(context.Context, metav1.ListOptions) (*appsv1.DeploymentList, error) ListPods(context.Context, metav1.ListOptions) (*corev1.PodList, error) ListRollouts(context.Context, metav1.ListOptions) (*unstructured.UnstructuredList, error) + ListVulnerabilityReports(context.Context, metav1.ListOptions) (*unstructured.UnstructuredList, error) Close() } @@ -274,6 +283,10 @@ func (client *kubeSnapshotClient) ListRollouts(ctx context.Context, options meta return client.dynamic.Resource(rolloutGVR).Namespace("").List(ctx, options) } +func (client *kubeSnapshotClient) ListVulnerabilityReports(ctx context.Context, options metav1.ListOptions) (*unstructured.UnstructuredList, error) { + return client.dynamic.Resource(vulnerabilityReportGVR).Namespace("").List(ctx, options) +} + func (client *kubeSnapshotClient) Close() { if client != nil && client.http != nil { client.http.CloseIdleConnections() @@ -285,7 +298,7 @@ func collectFacts(ctx context.Context, client snapshotClient, spoke hubfleet.Spo return nil, fmt.Errorf("snapshot client is required") } remaining := maxResources - facts := make([]fleet.Evidence, 0, maxResources*2) + facts := make([]fleet.Evidence, 0, maxResources*2+maxVulnerabilityReports) deployments, err := listDeployments(ctx, client, &remaining) if err != nil { return nil, err @@ -307,6 +320,11 @@ func collectFacts(ctx context.Context, client snapshotClient, spoke hubfleet.Spo for index := range rollouts { facts = append(facts, rolloutFacts(spoke.ID, rollouts[index], observedAt)...) } + cveFacts, err := collectCVEFacts(ctx, client, spoke.ID, podImageDigestSet(pods), observedAt) + if err != nil { + return nil, err + } + facts = append(facts, cveFacts...) return facts, nil } @@ -381,6 +399,14 @@ func listOptions(continueToken string, remaining int) metav1.ListOptions { return metav1.ListOptions{Limit: int64(limit), Continue: continueToken} } +func vulnerabilityReportListOptions(continueToken string, remaining int) metav1.ListOptions { + limit := remaining + if limit > vulnerabilityReportPageSize { + limit = vulnerabilityReportPageSize + } + return metav1.ListOptions{Limit: int64(limit), Continue: continueToken} +} + func appendPage[T any](items *[]T, page []T, continueToken string, remaining *int) error { if len(page) > *remaining || (len(page) == *remaining && continueToken != "") { return fmt.Errorf("direct OCM snapshot exceeds the bounded resource limit") @@ -438,6 +464,172 @@ func podImageDigests(pod corev1.Pod) []string { return digests } +func podImageDigestSet(pods []corev1.Pod) map[string]struct{} { + digests := make(map[string]struct{}) + for index := range pods { + for _, digest := range podImageDigests(pods[index]) { + digests[digest] = struct{}{} + } + } + return digests +} + +type cveAggregate struct { + identifiers map[string]struct{} + severity string +} + +func collectCVEFacts( + ctx context.Context, + client snapshotClient, + spokeID string, + knownDigests map[string]struct{}, + observedAt time.Time, +) ([]fleet.Evidence, error) { + if len(knownDigests) == 0 { + return nil, nil + } + remaining := maxVulnerabilityReports + continueToken := "" + aggregates := make(map[string]cveAggregate) + for pages := 0; ; pages++ { + if pages >= maxVulnerabilityReportPages { + return nil, fmt.Errorf("direct OCM snapshot exceeds the bounded vulnerability report page limit") + } + page, err := client.ListVulnerabilityReports(ctx, vulnerabilityReportListOptions(continueToken, remaining)) + if apierrors.IsNotFound(err) && continueToken == "" { + return []fleet.Evidence{}, nil + } + if err != nil { + return nil, contextOrGeneric(ctx, "list vulnerability reports") + } + if len(page.Items) > remaining || (len(page.Items) == remaining && page.GetContinue() != "") { + return nil, fmt.Errorf("direct OCM snapshot exceeds the bounded vulnerability report limit") + } + for index := range page.Items { + digest, identifiers, severity, found := reportCVEObservation(page.Items[index], knownDigests) + if !found { + continue + } + aggregate := aggregates[digest] + if aggregate.identifiers == nil { + aggregate.identifiers = make(map[string]struct{}, len(identifiers)) + } + for _, identifier := range identifiers { + aggregate.identifiers[identifier] = struct{}{} + } + if cveSeverityRank(severity) > cveSeverityRank(aggregate.severity) { + aggregate.severity = severity + } + aggregates[digest] = aggregate + } + remaining -= len(page.Items) + continueToken = page.GetContinue() + if continueToken == "" { + break + } + } + digests := make([]string, 0, len(aggregates)) + for digest := range aggregates { + digests = append(digests, digest) + } + sort.Strings(digests) + facts := make([]fleet.Evidence, 0, len(digests)) + for _, digest := range digests { + aggregate := aggregates[digest] + identifiers := make([]string, 0, len(aggregate.identifiers)) + for identifier := range aggregate.identifiers { + identifiers = append(identifiers, identifier) + } + observation, err := fleet.CanonicalCVEObservation(digest, identifiers, aggregate.severity) + if err != nil { + return nil, fmt.Errorf("normalize vulnerability report observation: %w", err) + } + payload, err := json.Marshal(observation) + if err != nil { + return nil, fmt.Errorf("encode normalized vulnerability report observation: %w", err) + } + facts = append(facts, fleet.Evidence{ + Ref: fleet.ResourceRef{SourceKind: hubfleet.SourceKind, Scope: spokeID, Kind: "Image", Name: digest}, + Kind: fleet.FactCVE, + Observed: payload, + ObservedAt: observedAt, + Source: spokeID, + Provenance: fleet.Provenance{Adapter: hubfleet.SourceKind, ProtocolV: protocolVersion}, + }) + } + return facts, nil +} + +func reportCVEObservation(report unstructured.Unstructured, knownDigests map[string]struct{}) (string, []string, string, bool) { + encoded, err := json.Marshal(report.Object) + if err != nil || len(encoded) > maxVulnerabilityReportBytes { + return "", nil, "", false + } + digest, found, err := unstructured.NestedString(report.Object, "report", "artifact", "digest") + if err != nil || !found || fleet.ValidateImageDigest(digest) != nil { + return "", nil, "", false + } + if _, exists := knownDigests[digest]; !exists { + return "", nil, "", false + } + vulnerabilities, found, err := unstructured.NestedSlice(report.Object, "report", "vulnerabilities") + if err != nil || !found || len(vulnerabilities) == 0 || len(vulnerabilities) > 256 { + return "", nil, "", false + } + identifiers := make([]string, 0, len(vulnerabilities)) + seen := make(map[string]struct{}, len(vulnerabilities)) + severity := "" + for _, raw := range vulnerabilities { + vulnerability, ok := raw.(map[string]any) + if !ok { + return "", nil, "", false + } + identifier, found, err := unstructured.NestedString(vulnerability, "vulnerabilityID") + if err != nil || !found { + return "", nil, "", false + } + identifier, err = fleet.NormalizeCVEIdentifier(identifier) + if err != nil { + return "", nil, "", false + } + if _, exists := seen[identifier]; exists { + return "", nil, "", false + } + seen[identifier] = struct{}{} + reportedSeverity, found, err := unstructured.NestedString(vulnerability, "severity") + if err != nil || !found { + return "", nil, "", false + } + reportedSeverity, err = fleet.NormalizeCVESeverity(reportedSeverity) + if err != nil { + return "", nil, "", false + } + if cveSeverityRank(reportedSeverity) > cveSeverityRank(severity) { + severity = reportedSeverity + } + identifiers = append(identifiers, identifier) + } + return digest, identifiers, severity, true +} + +func cveSeverityRank(severity string) int { + switch severity { + case "critical": + return 5 + case "high": + return 4 + case "medium": + return 3 + case "low": + return 2 + case "unknown": + return 1 + default: + return 0 + } +} + func rolloutFacts(spokeID string, rollout unstructured.Unstructured, observedAt time.Time) []fleet.Evidence { replicas, _, _ := unstructured.NestedInt64(rollout.Object, "status", "replicas") available, _, _ := unstructured.NestedInt64(rollout.Object, "status", "availableReplicas") diff --git a/internal/hubocm/direct_test.go b/internal/hubocm/direct_test.go index 52b0830..3672d21 100644 --- a/internal/hubocm/direct_test.go +++ b/internal/hubocm/direct_test.go @@ -17,9 +17,11 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/rest" ktesting "k8s.io/client-go/testing" @@ -125,6 +127,9 @@ func TestSnapshotPinsMSACredentialTLSAndNormalizedFacts(t *testing.T) { deployments: []*appsv1.DeploymentList{{Items: []appsv1.Deployment{deployment}}}, pods: []*corev1.PodList{{Items: []corev1.Pod{pod}}}, rollouts: []*unstructured.UnstructuredList{{}}, + vulnerabilityReports: []*unstructured.UnstructuredList{{Items: []unstructured.Unstructured{vulnerabilityReport( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "CVE-2026-0001", "HIGH", + )}}}, } adapter := testAdapter(t, reader, client, now) @@ -135,8 +140,8 @@ func TestSnapshotPinsMSACredentialTLSAndNormalizedFacts(t *testing.T) { if err := hubfleet.ValidateSnapshot(hubfleet.Spoke{ID: "spoke-a", ManagedClusterRef: "ocm/spoke-a"}, snapshot, now); err != nil { t.Fatalf("ValidateSnapshot() error = %v", err) } - if len(snapshot.Facts) != 4 || client.closed != 1 { - t.Fatalf("snapshot facts/close = %d/%d, want 4/1", len(snapshot.Facts), client.closed) + if len(snapshot.Facts) != 5 || client.closed != 1 { + t.Fatalf("snapshot facts/close = %d/%d, want 5/1", len(snapshot.Facts), client.closed) } if len(client.configs) != 1 { t.Fatalf("client configs = %d, want 1", len(client.configs)) @@ -156,6 +161,69 @@ func TestSnapshotPinsMSACredentialTLSAndNormalizedFacts(t *testing.T) { if fact.Kind == fleet.FactInventory && fact.Ref.Kind == "Pod" && !strings.Contains(string(fact.Observed), "\"image_digests\":[\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"]") { t.Fatalf("pod inventory did not retain the canonical runtime digest: %s", fact.Observed) } + if fact.Kind == fleet.FactCVE && (!strings.Contains(string(fact.Observed), "\"CVE-2026-0001\"") || strings.Contains(string(fact.Observed), "scanner")) { + t.Fatalf("CVE fact was not normalized: %s", fact.Observed) + } + } +} + +func TestReportCVEObservationRequiresSameSnapshotRuntimeProof(t *testing.T) { + t.Parallel() + + digest := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + known := map[string]struct{}{digest: {}} + tests := []struct { + name string + report unstructured.Unstructured + wantFound bool + }{ + {name: "exact runtime digest", report: vulnerabilityReport(digest, "CVE-2026-0001", "HIGH"), wantFound: true}, + {name: "unknown digest", report: vulnerabilityReport("sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "CVE-2026-0001", "HIGH")}, + {name: "malformed identifier", report: vulnerabilityReport(digest, "CVE-2026-1", "HIGH")}, + {name: "unsupported severity", report: vulnerabilityReport(digest, "CVE-2026-0001", "urgent")}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotDigest, identifiers, severity, found := reportCVEObservation(test.report, known) + if found != test.wantFound { + t.Fatalf("reportCVEObservation() found = %v, want %v", found, test.wantFound) + } + if !test.wantFound { + return + } + if gotDigest != digest || !slices.Equal(identifiers, []string{"CVE-2026-0001"}) || severity != "high" { + t.Fatalf("reportCVEObservation() = %q/%#v/%q", gotDigest, identifiers, severity) + } + }) + } +} + +func TestCollectCVEFactsTreatsMissingCRDAsOptional(t *testing.T) { + t.Parallel() + + client := &fakeSnapshotClient{vulnerabilityReportErr: apierrors.NewNotFound(schema.GroupResource{Group: vulnerabilityReportGVR.Group, Resource: vulnerabilityReportGVR.Resource}, "missing")} + facts, err := collectCVEFacts(context.Background(), client, "spoke-a", map[string]struct{}{ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": {}, + }, time.Now().UTC()) + if err != nil || len(facts) != 0 { + t.Fatalf("collectCVEFacts() = %#v, %v", facts, err) + } +} + +func TestCollectCVEFactsBoundsEmptyContinuationPages(t *testing.T) { + t.Parallel() + + pages := make([]*unstructured.UnstructuredList, maxVulnerabilityReportPages) + for index := range pages { + pages[index] = &unstructured.UnstructuredList{} + pages[index].SetContinue("unexpected-continuation-" + strconv.Itoa(index)) + } + client := &fakeSnapshotClient{vulnerabilityReports: pages} + _, err := collectCVEFacts(context.Background(), client, "spoke-a", map[string]struct{}{ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": {}, + }, time.Now().UTC()) + if err == nil || client.vulnerabilityReportCalls != maxVulnerabilityReportPages { + t.Fatalf("collectCVEFacts() error/calls = %v/%d", err, client.vulnerabilityReportCalls) } } @@ -309,13 +377,16 @@ func (reader *rotatingCredentialReader) Read(_ context.Context, _ tenancy.Worksp } type fakeSnapshotClient struct { - deployments []*appsv1.DeploymentList - pods []*corev1.PodList - rollouts []*unstructured.UnstructuredList - configs []*rest.Config - closed int - podCalls int - rolloutCalls int + deployments []*appsv1.DeploymentList + pods []*corev1.PodList + rollouts []*unstructured.UnstructuredList + vulnerabilityReports []*unstructured.UnstructuredList + vulnerabilityReportErr error + vulnerabilityReportCalls int + configs []*rest.Config + closed int + podCalls int + rolloutCalls int } func (client *fakeSnapshotClient) ListDeployments(_ context.Context, _ metav1.ListOptions) (*appsv1.DeploymentList, error) { @@ -347,6 +418,19 @@ func (client *fakeSnapshotClient) ListRollouts(_ context.Context, _ metav1.ListO return page, nil } +func (client *fakeSnapshotClient) ListVulnerabilityReports(_ context.Context, _ metav1.ListOptions) (*unstructured.UnstructuredList, error) { + client.vulnerabilityReportCalls++ + if client.vulnerabilityReportErr != nil { + return nil, client.vulnerabilityReportErr + } + if len(client.vulnerabilityReports) == 0 { + return &unstructured.UnstructuredList{}, nil + } + page := client.vulnerabilityReports[0] + client.vulnerabilityReports = client.vulnerabilityReports[1:] + return page, nil +} + func (client *fakeSnapshotClient) Close() { client.closed++ } type fakeTunnelFactory struct { @@ -404,3 +488,16 @@ func testAdapter(t *testing.T, reader CredentialReader, client *fakeSnapshotClie } func pointer[T any](value T) *T { return &value } + +func vulnerabilityReport(digest, identifier, severity string) unstructured.Unstructured { + return unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "aquasecurity.github.io/v1alpha1", + "kind": "VulnerabilityReport", + "metadata": map[string]any{"name": "fixture", "namespace": "apps", "labels": map[string]any{"scanner": "not-retained"}}, + "report": map[string]any{ + "artifact": map[string]any{"digest": digest, "repository": "registry.example/private"}, + "scanner": map[string]any{"name": "not-retained"}, + "vulnerabilities": []any{map[string]any{"vulnerabilityID": identifier, "severity": severity, "description": "not-retained"}}, + }, + }} +} diff --git a/internal/hubocm/ocm_integration_test.go b/internal/hubocm/ocm_integration_test.go index 72f2afd..886a9b9 100644 --- a/internal/hubocm/ocm_integration_test.go +++ b/internal/hubocm/ocm_integration_test.go @@ -61,8 +61,8 @@ func TestDirectClusterProxyM0(t *testing.T) { if err := hubfleet.ValidateSnapshot(spoke, snapshot, time.Now().UTC()); err != nil { t.Fatal("direct OCM snapshot did not meet the fleet contract") } - if !hasInventoryFor(snapshot, "Deployment") || !hasInventoryFor(snapshot, "Pod") { - t.Fatal("direct OCM snapshot did not contain the scoped deployment and pod inventory") + if !hasInventoryFor(snapshot, "Deployment") || !hasInventoryFor(snapshot, "Pod") || !hasCVEFor(snapshot) { + t.Fatal("direct OCM snapshot did not contain the scoped inventory and runtime-proven CVE evidence") } } @@ -154,3 +154,12 @@ func hasInventoryFor(snapshot hubfleet.Snapshot, kind string) bool { } return false } + +func hasCVEFor(snapshot hubfleet.Snapshot) bool { + for _, fact := range snapshot.Facts { + if fact.Kind == "cve" && fact.Ref.Kind == "Image" && len(fact.Observed) != 0 { + return true + } + } + return false +} diff --git a/internal/hubruntime/config.go b/internal/hubruntime/config.go index 22c64ce..7a59bf1 100644 --- a/internal/hubruntime/config.go +++ b/internal/hubruntime/config.go @@ -122,8 +122,13 @@ func NewFromEnvironment(ctx context.Context, logger *slog.Logger) (*Runtime, err cleanup() return nil, fmt.Errorf("construct hub runtime: image search configuration is invalid") } + cveSearcher, err := hubfleet.NewCVESearcher(hubfleet.CVESearcherConfig{Querier: database, PEP: enforcer}) + if err != nil { + cleanup() + return nil, fmt.Errorf("construct hub runtime: CVE search configuration is invalid") + } handler, err := hubserver.NewFleetHandler(hubserver.FleetHandlerConfig{ - Verifier: verifier, AuthObserver: authObserver, Collector: collector, Reader: database, ImageSearcher: imageSearcher, PEP: enforcer, + Verifier: verifier, AuthObserver: authObserver, Collector: collector, Reader: database, ImageSearcher: imageSearcher, CVESearcher: cveSearcher, CVEIdentifierSearcher: cveSearcher, PEP: enforcer, }) if err != nil { cleanup() diff --git a/internal/hubruntime/ocm_integration_test.go b/internal/hubruntime/ocm_integration_test.go index 71b893a..35e7323 100644 --- a/internal/hubruntime/ocm_integration_test.go +++ b/internal/hubruntime/ocm_integration_test.go @@ -71,10 +71,14 @@ func TestHubRuntimeDirectClusterProxyM0(t *testing.T) { if err != nil { t.Fatal(err) } + cveSearcher, err := hubfleet.NewCVESearcher(hubfleet.CVESearcherConfig{Querier: store, PEP: enforcer}) + if err != nil { + t.Fatal(err) + } now := time.Now().UTC() verifier, privateKey := m0RuntimeVerifier(t, now) handler, err := hubserver.NewFleetHandler(hubserver.FleetHandlerConfig{ - Verifier: verifier, Collector: collector, Reader: store, ImageSearcher: imageSearcher, PEP: enforcer, + Verifier: verifier, Collector: collector, Reader: store, ImageSearcher: imageSearcher, CVESearcher: cveSearcher, CVEIdentifierSearcher: cveSearcher, PEP: enforcer, }) if err != nil { t.Fatal(err) @@ -136,6 +140,40 @@ func TestHubRuntimeDirectClusterProxyM0(t *testing.T) { !slices.Equal([]string{imageResult.Facts[0].Ref.Scope, imageResult.Facts[1].Ref.Scope}, []string{"spoke-a", "spoke-b"}) { t.Fatalf("runtime exact image search = %#v", imageResult) } + cveResponse := m0RuntimeRequest(t, ctx, client, http.MethodGet, endpoint+"/fleet/images/"+digest+"/cves", token) + defer cveResponse.Body.Close() + if cveResponse.StatusCode != http.StatusOK { + t.Fatalf("runtime CVE search status = %d", cveResponse.StatusCode) + } + var cveResult fleet.QueryResult + if err := json.NewDecoder(cveResponse.Body).Decode(&cveResult); err != nil { + t.Fatal(err) + } + if len(cveResult.Facts) != 2 || cveResult.Coverage.Requested != 2 || cveResult.Coverage.Reachable != 2 || + cveResult.Facts[0].Ref.Kind != "Image" || cveResult.Facts[1].Ref.Kind != "Image" || + !slices.Equal([]string{cveResult.Facts[0].Ref.Scope, cveResult.Facts[1].Ref.Scope}, []string{"spoke-a", "spoke-b"}) { + t.Fatalf("runtime exact CVE search = %#v", cveResult) + } + for _, fact := range cveResult.Facts { + var observation fleet.CVEObservation + if err := json.Unmarshal(fact.Observed, &observation); err != nil || observation.Image != digest || + !slices.Equal(observation.IDs, []string{"CVE-2026-0001", "CVE-2026-0002"}) || observation.Severity != "high" { + t.Fatalf("runtime normalized CVE fact = %#v, error = %v", fact, err) + } + } + identifierResponse := m0RuntimeRequest(t, ctx, client, http.MethodGet, endpoint+"/fleet/cves/CVE-2026-0001", token) + defer identifierResponse.Body.Close() + if identifierResponse.StatusCode != http.StatusOK { + t.Fatalf("runtime CVE identifier search status = %d", identifierResponse.StatusCode) + } + var identifierResult fleet.QueryResult + if err := json.NewDecoder(identifierResponse.Body).Decode(&identifierResult); err != nil { + t.Fatal(err) + } + if len(identifierResult.Facts) != 2 || identifierResult.Coverage.Requested != 2 || identifierResult.Coverage.Reachable != 2 || + !slices.Equal([]string{identifierResult.Facts[0].Ref.Scope, identifierResult.Facts[1].Ref.Scope}, []string{"spoke-a", "spoke-b"}) { + t.Fatalf("runtime exact CVE identifier search = %#v", identifierResult) + } stopServer() if err := <-serverDone; err != nil { t.Fatal(err) @@ -233,8 +271,14 @@ func (store *m0RuntimeStore) QueryFleet( if err := scope.RequireWorkspace(m0RuntimeWorkspaceID); err != nil { return fleet.QueryResult{}, err } - if len(query.Kinds) != 1 || query.Kinds[0] != fleet.FactInventory || query.Selector.ResourceKind != "Pod" || - fleet.ValidateImageDigest(query.Selector.Image) != nil || freshness < time.Second || now.IsZero() { + if len(query.Kinds) != 1 || freshness < time.Second || now.IsZero() { + return fleet.QueryResult{}, fmt.Errorf("M0 runtime store received an unsupported fleet query") + } + wantInventory := query.Kinds[0] == fleet.FactInventory && query.Selector.ResourceKind == "Pod" && fleet.ValidateImageDigest(query.Selector.Image) == nil + wantCVEImage := query.Kinds[0] == fleet.FactCVE && query.Selector.ResourceKind == "Image" && fleet.ValidateImageDigest(query.Selector.Image) == nil + canonicalIdentifier, identifierErr := fleet.NormalizeCVEIdentifier(query.Selector.CVE) + wantCVEIdentifier := query.Kinds[0] == fleet.FactCVE && query.Selector.ResourceKind == "Image" && query.Selector.Image == "" && identifierErr == nil && canonicalIdentifier == query.Selector.CVE + if !wantInventory && !wantCVEImage && !wantCVEIdentifier { return fleet.QueryResult{}, fmt.Errorf("M0 runtime store received an unsupported fleet query") } store.mu.Lock() @@ -256,7 +300,13 @@ func (store *m0RuntimeStore) QueryFleet( result.Coverage.Stale = append(result.Coverage.Stale, spoke.ID) } for _, evidence := range snapshot.Facts { - if evidence.Kind != fleet.FactInventory || evidence.Ref.Kind != "Pod" || !m0RuntimeFactHasDigest(evidence, query.Selector.Image) { + if wantInventory && (evidence.Kind != fleet.FactInventory || evidence.Ref.Kind != "Pod" || !m0RuntimeFactHasDigest(evidence, query.Selector.Image)) { + continue + } + if wantCVEImage && (evidence.Kind != fleet.FactCVE || evidence.Ref.Kind != "Image" || !m0RuntimeCVEFactHasDigest(evidence, query.Selector.Image)) { + continue + } + if wantCVEIdentifier && (evidence.Kind != fleet.FactCVE || evidence.Ref.Kind != "Image" || !m0RuntimeCVEFactHasIdentifier(evidence, query.Selector.CVE)) { continue } result.Facts = append(result.Facts, fleet.Fact{Evidence: evidence, Workspace: string(scope.WorkspaceID()), Stale: stale}) @@ -309,6 +359,16 @@ func m0RuntimeFactHasDigest(evidence fleet.Evidence, digest string) bool { return json.Unmarshal(evidence.Observed, &observed) == nil && slices.Contains(observed.ImageDigests, digest) } +func m0RuntimeCVEFactHasDigest(evidence fleet.Evidence, digest string) bool { + var observed fleet.CVEObservation + return json.Unmarshal(evidence.Observed, &observed) == nil && observed.Image == digest && fleet.ValidateCVEObservation(observed) == nil +} + +func m0RuntimeCVEFactHasIdentifier(evidence fleet.Evidence, identifier string) bool { + var observed fleet.CVEObservation + return json.Unmarshal(evidence.Observed, &observed) == nil && fleet.ValidateCVEObservation(observed) == nil && slices.Contains(observed.IDs, identifier) +} + type m0RuntimeClaims struct { Memberships map[string]tenancy.Role `json:"memberships"` jwt.RegisteredClaims diff --git a/internal/hubserver/fleet.go b/internal/hubserver/fleet.go index 9d2cdcc..936c2eb 100644 --- a/internal/hubserver/fleet.go +++ b/internal/hubserver/fleet.go @@ -29,14 +29,26 @@ type FleetImageSearcher interface { Search(context.Context, tenancy.Scope, hubfleet.ImageSearchRequest) (fleet.QueryResult, error) } +// FleetCVESearcher resolves one exact immutable image's normalized CVE observations. +type FleetCVESearcher interface { + Search(context.Context, tenancy.Scope, hubfleet.ImageSearchRequest) (fleet.QueryResult, error) +} + +// FleetCVEIdentifierSearcher resolves normalized image CVE observations for one exact identifier. +type FleetCVEIdentifierSearcher interface { + SearchByIdentifier(context.Context, tenancy.Scope, hubfleet.CVEIdentifierSearchRequest) (fleet.QueryResult, error) +} + // FleetHandlerConfig supplies the authenticated dependencies for the fixed hub fleet API. type FleetHandlerConfig struct { - Verifier Verifier - AuthObserver AuthObserver - Collector FleetRefresher - Reader hubfleet.FleetReader - ImageSearcher FleetImageSearcher - PEP *pep.Enforcer + Verifier Verifier + AuthObserver AuthObserver + Collector FleetRefresher + Reader hubfleet.FleetReader + ImageSearcher FleetImageSearcher + CVESearcher FleetCVESearcher + CVEIdentifierSearcher FleetCVEIdentifierSearcher + PEP *pep.Enforcer } // NewFleetHandler constructs the fixed, authenticated hub read surface. @@ -50,7 +62,7 @@ func NewFleetHandler(config FleetHandlerConfig) (http.Handler, error) { handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { setNoStore(response.Header()) - workspaceID, operation, imageDigest, ok := parseFleetRoute(request.URL) + workspaceID, operation, selector, ok := parseFleetRoute(request.URL) if !ok { writeFleetError(response, http.StatusNotFound, "not_found") return @@ -107,12 +119,44 @@ func NewFleetHandler(config FleetHandlerConfig) (http.Handler, error) { writeFleetError(response, http.StatusMethodNotAllowed, "method_not_allowed") return } - result, err := config.ImageSearcher.Search(request.Context(), scope, hubfleet.ImageSearchRequest{Digest: imageDigest}) + result, err := config.ImageSearcher.Search(request.Context(), scope, hubfleet.ImageSearchRequest{Digest: selector}) if err != nil { writeFleetError(response, http.StatusServiceUnavailable, "image_search_unavailable") return } writeFleetJSON(response, http.StatusOK, result) + case fleetOperationCVESearch: + if request.Method != http.MethodGet { + response.Header().Set("Allow", http.MethodGet) + writeFleetError(response, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + if config.CVESearcher == nil { + writeFleetError(response, http.StatusServiceUnavailable, "cve_search_unavailable") + return + } + result, err := config.CVESearcher.Search(request.Context(), scope, hubfleet.ImageSearchRequest{Digest: selector}) + if err != nil { + writeFleetError(response, http.StatusServiceUnavailable, "cve_search_unavailable") + return + } + writeFleetJSON(response, http.StatusOK, result) + case fleetOperationCVEIdentifierSearch: + if request.Method != http.MethodGet { + response.Header().Set("Allow", http.MethodGet) + writeFleetError(response, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + if config.CVEIdentifierSearcher == nil { + writeFleetError(response, http.StatusServiceUnavailable, "cve_identifier_search_unavailable") + return + } + result, err := config.CVEIdentifierSearcher.SearchByIdentifier(request.Context(), scope, hubfleet.CVEIdentifierSearchRequest{Identifier: selector}) + if err != nil { + writeFleetError(response, http.StatusServiceUnavailable, "cve_identifier_search_unavailable") + return + } + writeFleetJSON(response, http.StatusOK, result) default: writeFleetError(response, http.StatusNotFound, "not_found") } @@ -127,6 +171,8 @@ const ( fleetOperationRead fleetOperation = iota + 1 fleetOperationRefresh fleetOperationImageSearch + fleetOperationCVESearch + fleetOperationCVEIdentifierSearch ) func parseFleetRoute(requestURL *url.URL) (tenancy.WorkspaceID, fleetOperation, string, bool) { @@ -155,15 +201,38 @@ func parseFleetRoute(requestURL *url.URL) (tenancy.WorkspaceID, fleetOperation, case "fleet:refresh": return workspaceID, fleetOperationRefresh, "", true default: + identifierSegment, found := strings.CutPrefix(resource, "fleet/cves/") + if found { + if identifierSegment == "" || strings.Contains(identifierSegment, "/") { + return "", 0, "", false + } + identifier, err := url.PathUnescape(identifierSegment) + if err != nil || url.PathEscape(identifier) != identifierSegment { + return "", 0, "", false + } + canonical, err := fleet.NormalizeCVEIdentifier(identifier) + if err != nil || canonical != identifier { + return "", 0, "", false + } + return workspaceID, fleetOperationCVEIdentifierSearch, identifier, true + } digestSegment, found := strings.CutPrefix(resource, "fleet/images/") - if !found || digestSegment == "" || strings.Contains(digestSegment, "/") { + if !found || digestSegment == "" { + return "", 0, "", false + } + operation := fleetOperationImageSearch + if strings.HasSuffix(digestSegment, "/cves") { + operation = fleetOperationCVESearch + digestSegment = strings.TrimSuffix(digestSegment, "/cves") + } + if digestSegment == "" || strings.Contains(digestSegment, "/") { return "", 0, "", false } digest, err := url.PathUnescape(digestSegment) if err != nil || url.PathEscape(digest) != digestSegment || fleet.ValidateImageDigest(digest) != nil { return "", 0, "", false } - return workspaceID, fleetOperationImageSearch, digest, true + return workspaceID, operation, digest, true } } diff --git a/internal/hubserver/fleet_test.go b/internal/hubserver/fleet_test.go index 3f15bc5..bcf9dad 100644 --- a/internal/hubserver/fleet_test.go +++ b/internal/hubserver/fleet_test.go @@ -48,9 +48,21 @@ func (function fleetImageSearcherFunc) Search( return function(ctx, scope, request) } +type fleetCVEIdentifierSearcherFunc func(context.Context, tenancy.Scope, hubfleet.CVEIdentifierSearchRequest) (fleet.QueryResult, error) + +func (function fleetCVEIdentifierSearcherFunc) SearchByIdentifier( + ctx context.Context, + scope tenancy.Scope, + request hubfleet.CVEIdentifierSearchRequest, +) (fleet.QueryResult, error) { + return function(ctx, scope, request) +} + var _ FleetRefresher = fleetRefresherFunc(nil) var _ hubfleet.FleetReader = fleetReaderFunc(nil) var _ FleetImageSearcher = fleetImageSearcherFunc(nil) +var _ FleetCVESearcher = fleetImageSearcherFunc(nil) +var _ FleetCVEIdentifierSearcher = fleetCVEIdentifierSearcherFunc(nil) func TestFleetHandlerRefreshUsesOnlySignedWorkspaceScope(t *testing.T) { now := time.Date(2026, 7, 14, 7, 0, 0, 0, time.UTC) @@ -277,9 +289,16 @@ func TestFleetHandlerRejectsUnsupportedRoutesMethodsAndQueries(t *testing.T) { {name: "unknown route", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet/extra", wantCode: http.StatusNotFound, wantBody: "{\"error\":\"not_found\"}\n"}, {name: "query rejected", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet?freshness=1s", wantCode: http.StatusNotFound, wantBody: "{\"error\":\"not_found\"}\n"}, {name: "noncanonical image", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet/images/registry.example%2Fpayments%3Alatest", wantCode: http.StatusNotFound, wantBody: "{\"error\":\"not_found\"}\n"}, + {name: "CVE suffix rejects extra path", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet/images/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/cves/extra", wantCode: http.StatusNotFound, wantBody: "{\"error\":\"not_found\"}\n"}, + {name: "CVE identifier rejects lower case", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet/cves/cve-2026-0001", wantCode: http.StatusNotFound, wantBody: "{\"error\":\"not_found\"}\n"}, + {name: "CVE identifier rejects glob", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet/cves/CVE-2026-0001*", wantCode: http.StatusNotFound, wantBody: "{\"error\":\"not_found\"}\n"}, + {name: "CVE identifier rejects extra path", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet/cves/CVE-2026-0001/extra", wantCode: http.StatusNotFound, wantBody: "{\"error\":\"not_found\"}\n"}, {name: "read method", method: http.MethodPost, target: "/v1/workspaces/workspace-a/fleet", wantCode: http.StatusMethodNotAllowed, wantBody: "{\"error\":\"method_not_allowed\"}\n", wantAllow: http.MethodGet}, {name: "refresh method", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet:refresh", wantCode: http.StatusMethodNotAllowed, wantBody: "{\"error\":\"method_not_allowed\"}\n", wantAllow: http.MethodPost}, {name: "image method", method: http.MethodPost, target: "/v1/workspaces/workspace-a/fleet/images/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", wantCode: http.StatusMethodNotAllowed, wantBody: "{\"error\":\"method_not_allowed\"}\n", wantAllow: http.MethodGet}, + {name: "CVE method", method: http.MethodPost, target: "/v1/workspaces/workspace-a/fleet/images/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/cves", wantCode: http.StatusMethodNotAllowed, wantBody: "{\"error\":\"method_not_allowed\"}\n", wantAllow: http.MethodGet}, + {name: "CVE identifier method", method: http.MethodPost, target: "/v1/workspaces/workspace-a/fleet/cves/CVE-2026-0001", wantCode: http.StatusMethodNotAllowed, wantBody: "{\"error\":\"method_not_allowed\"}\n", wantAllow: http.MethodGet}, + {name: "CVE identifier unavailable", method: http.MethodGet, target: "/v1/workspaces/workspace-a/fleet/cves/CVE-2026-0001", wantCode: http.StatusServiceUnavailable, wantBody: "{\"error\":\"cve_identifier_search_unavailable\"}\n"}, } { t.Run(test.name, func(t *testing.T) { response := httptest.NewRecorder() @@ -365,6 +384,89 @@ func TestFleetHandlerSearchesOneExactImageDigestInSignedWorkspace(t *testing.T) } } +func TestFleetHandlerSearchesCVEsForOneExactImageDigestInSignedWorkspace(t *testing.T) { + now := time.Date(2026, 7, 14, 19, 0, 0, 0, time.UTC) + verifier, privateKey := fleetTestVerifier(t, now) + digest := "sha256:" + strings.Repeat("a", 64) + called := false + handler, err := NewFleetHandler(FleetHandlerConfig{ + Verifier: verifier, + Collector: fleetRefresherFunc(func(context.Context, tenancy.Scope) (fleet.Coverage, error) { + t.Fatal("CVE search reached collector") + return fleet.Coverage{}, nil + }), + Reader: fleetReaderFunc(func(context.Context, tenancy.Scope, time.Duration, time.Time) (fleet.FleetResult, error) { + t.Fatal("CVE search reached fleet reader") + return fleet.FleetResult{}, nil + }), + ImageSearcher: fleetImageSearcherFunc(func(context.Context, tenancy.Scope, hubfleet.ImageSearchRequest) (fleet.QueryResult, error) { + t.Fatal("CVE search reached image searcher") + return fleet.QueryResult{}, nil + }), + CVESearcher: fleetImageSearcherFunc(func(_ context.Context, scope tenancy.Scope, request hubfleet.ImageSearchRequest) (fleet.QueryResult, error) { + called = true + if scope.WorkspaceID() != "workspace-a" || request.Digest != digest || request.Limit != 0 { + t.Fatalf("CVE search scope/request = %#v/%#v", scope, request) + } + return fleet.QueryResult{Facts: []fleet.Fact{{Workspace: "workspace-a", Evidence: fleet.Evidence{Ref: fleet.ResourceRef{Scope: "spoke-a", Kind: "Image", Name: digest}}}}, Coverage: fleet.Coverage{Requested: 2, Reachable: 2}}, nil + }), + PEP: fleetTestPEP(t, pep.AllowReadHook{}), + }) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, authenticatedFleetRequest(t, http.MethodGet, "/v1/workspaces/workspace-a/fleet/images/"+digest+"/cves", privateKey, now)) + if response.Code != http.StatusOK || !called || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status/called/cache = %d/%t/%q", response.Code, called, response.Header().Get("Cache-Control")) + } + var result fleet.QueryResult + if err := json.NewDecoder(response.Body).Decode(&result); err != nil || len(result.Facts) != 1 || result.Facts[0].Ref.Kind != "Image" || !result.Coverage.Complete() { + t.Fatalf("CVE result = %#v, error = %v", result, err) + } +} + +func TestFleetHandlerSearchesOneExactCVEIdentifierInSignedWorkspace(t *testing.T) { + now := time.Date(2026, 7, 14, 20, 0, 0, 0, time.UTC) + verifier, privateKey := fleetTestVerifier(t, now) + called := false + handler, err := NewFleetHandler(FleetHandlerConfig{ + Verifier: verifier, + Collector: fleetRefresherFunc(func(context.Context, tenancy.Scope) (fleet.Coverage, error) { + t.Fatal("CVE identifier search reached collector") + return fleet.Coverage{}, nil + }), + Reader: fleetReaderFunc(func(context.Context, tenancy.Scope, time.Duration, time.Time) (fleet.FleetResult, error) { + t.Fatal("CVE identifier search reached fleet reader") + return fleet.FleetResult{}, nil + }), + ImageSearcher: fleetImageSearcherFunc(func(context.Context, tenancy.Scope, hubfleet.ImageSearchRequest) (fleet.QueryResult, error) { + t.Fatal("CVE identifier search reached image searcher") + return fleet.QueryResult{}, nil + }), + CVEIdentifierSearcher: fleetCVEIdentifierSearcherFunc(func(_ context.Context, scope tenancy.Scope, request hubfleet.CVEIdentifierSearchRequest) (fleet.QueryResult, error) { + called = true + if scope.WorkspaceID() != "workspace-a" || request.Identifier != "CVE-2026-0001" || request.Limit != 0 { + t.Fatalf("CVE identifier search scope/request = %#v/%#v", scope, request) + } + return fleet.QueryResult{Facts: []fleet.Fact{{Workspace: "workspace-a", Evidence: fleet.Evidence{Ref: fleet.ResourceRef{Scope: "spoke-a", Kind: "Image", Name: "sha256:" + strings.Repeat("a", 64)}}}}, Coverage: fleet.Coverage{Requested: 2, Reachable: 2}}, nil + }), + PEP: fleetTestPEP(t, pep.AllowReadHook{}), + }) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, authenticatedFleetRequest(t, http.MethodGet, "/v1/workspaces/workspace-a/fleet/cves/CVE-2026-0001", privateKey, now)) + if response.Code != http.StatusOK || !called || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("status/called/cache = %d/%t/%q", response.Code, called, response.Header().Get("Cache-Control")) + } + var result fleet.QueryResult + if err := json.NewDecoder(response.Body).Decode(&result); err != nil || len(result.Facts) != 1 || result.Facts[0].Ref.Kind != "Image" || !result.Coverage.Complete() { + t.Fatalf("CVE identifier result = %#v, error = %v", result, err) + } +} + func TestNewFleetHandlerRejectsMissingDependencies(t *testing.T) { if _, err := NewFleetHandler(FleetHandlerConfig{}); err == nil { t.Fatal("NewFleetHandler accepted missing dependencies") diff --git a/internal/pep/pep.go b/internal/pep/pep.go index d7de486..1ddec42 100644 --- a/internal/pep/pep.go +++ b/internal/pep/pep.go @@ -25,16 +25,18 @@ type Verb string // Phase-1 read verbs. Future write verbs are added only with their typed argument schema and PDP path. const ( - VerbFleetRead Verb = "fleet.read" - VerbFleetCorrelate Verb = "fleet.correlate" - VerbFleetImageSearch Verb = "fleet.image.search" - VerbSpokeSnapshotRefresh Verb = "fleet.snapshot.refresh" + VerbFleetRead Verb = "fleet.read" + VerbFleetCorrelate Verb = "fleet.correlate" + VerbFleetImageSearch Verb = "fleet.image.search" + VerbFleetCVESearch Verb = "fleet.cve.search" + VerbFleetCVEIdentifierSearch Verb = "fleet.cve.identifier.search" + VerbSpokeSnapshotRefresh Verb = "fleet.snapshot.refresh" ) // Valid reports whether a verb belongs to the currently supported closed vocabulary. func (verb Verb) Valid() bool { switch verb { - case VerbFleetRead, VerbFleetCorrelate, VerbFleetImageSearch, VerbSpokeSnapshotRefresh: + case VerbFleetRead, VerbFleetCorrelate, VerbFleetImageSearch, VerbFleetCVESearch, VerbFleetCVEIdentifierSearch, VerbSpokeSnapshotRefresh: return true default: return false diff --git a/sessions/2026-07-14-e2-coverage-assessment.md b/sessions/2026-07-14-e2-coverage-assessment.md new file mode 100644 index 0000000..2548006 --- /dev/null +++ b/sessions/2026-07-14-e2-coverage-assessment.md @@ -0,0 +1,56 @@ +# Session — 2026-07-14 — E2 coverage assessment + +**Issue:** [#148](https://github.com/ArdurAI/sith/issues/148) +**Branch:** `gnanirahulnutakki/feat/e2-coverage-assessment` +**Base:** `origin/dev` at `0704cf37c042aba05b6644b710647971951dbc00` + +## [G] Goal + +Export a conservative, typed assessment of existing fleet coverage so a future policy layer can +abstain on stale, unreachable, unaccounted, or inconsistent evidence without reinterpreting raw +counters. + +## [S] Scope + +- Add a pure `fleet.Coverage` assessment contract and table-driven unit coverage. +- Prove the retained stale result from the existing real two-spoke read-federation test is + incomplete with explicit gaps. +- Do not add policy wiring, actions, endpoints, persistence, connectors, credentials, telemetry, + or any ClusterGateway change. + +## [A] Analysis and decision + +- `Coverage.Complete` previously only compared requested and reachable counts plus stale length; + it could not explain a gap and could report contradictory unreachable metadata as complete. +- The new assessment is evidence only, not an authorization decision. Empty requested coverage is + complete evidence; a future typed intent still owns target validation. +- A stale scope may overlap an unreachable scope because retained stale evidence is expected after + a failed refresh. Negative counts, contradictory accounting, blank names, and duplicate names + are inconsistent and fail closed. +- The assessment also rejects more stale names than requested scopes; it cannot prove that the + extra names belong to the requested set, so treating them as harmless would be fail-open. + +## [T] Tests and evidence + +- PASS — focused `go test -race -count=1 ./internal/fleet ./internal/hubfleet` after each contract + change; table coverage includes complete, empty, stale, unreachable, unaccounted, + contradictory, duplicate, blank, and surplus-stale scope cases. +- PASS — `make ci`: formatting, static analysis with zero findings, dependency vulnerability scan + with no findings, full race suite, the existing safety contracts, and binary integration tests. +- PASS — `make e2e-isolation`: PostgreSQL forced-RLS/destructive coverage plus the fixed + 50,000-execution workspace-isolation fuzz campaign. +- PASS — `make release-check`: two four-platform snapshot builds, identical archive digests, SPDX + SBOM generation, and formula rendering. +- PASS — final `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` in 169.870 seconds; + the two real spokes retained stale evidence after one failed collection and the assessment + reported explicit unreachable and stale gaps. +- Red-team review: the first peer-review attempt reached analysis but returned no completion after + a bounded wait. Manual review found and fixed the surplus-stale-cardinality consistency gap. + The final CodeRabbit review completed with zero findings across all four staged files. + +## [C] Checkpoint #1 + +- Conservative F2.5 coverage assessment implementation and all local validation are complete; + the signed commit for this checkpoint carries the matching GSTACK trailer. Open questions + touched: none; F2.5 exports evidence for a future policy layer but does not make or record a + policy decision. diff --git a/sessions/2026-07-14-e2-exact-cve-search.md b/sessions/2026-07-14-e2-exact-cve-search.md new file mode 100644 index 0000000..9a13520 --- /dev/null +++ b/sessions/2026-07-14-e2-exact-cve-search.md @@ -0,0 +1,18 @@ +# Session — 2026-07-14 — e2-exact-cve-search + +**Builder:** Gnani Rahul · **Model/effort:** implementation session · **Branch:** gnanirahulnutakki/feat/e2-exact-cve-search +**Slice(s):** E2 F2.4 · #152 · **Status:** in-progress + +--- + +[G] Goal: deliver #152, a tenant-scoped, exact canonical CVE identifier query over the runtime-proven, normalized CVE facts added by #150. +[S] Scope: a fixed authenticated `GET /v1/workspaces/{workspace}/fleet/cves/{CVE-YYYY-N...}` route, its dedicated PEP verb, one bounded PostgreSQL JSONB identifier predicate/index, and validation evidence. No scanner/operator, registry/SBOM/feed request, mutable image match, glob/list/severity query, raw report retention, write path, new credential, or ClusterGateway change. +[A] Action: added canonical identifier validation before the PEP/query seams; the dedicated `fleet.cve.identifier.search` verb; forced-RLS persisted query support limited to one `Image` CVE fact kind; a partial GIN index only over normalized `payload.ids`; the signed hub route; and runtime composition. The response remains the existing source-stamped `fleet.QueryResult` with stale/unreachable coverage. +[T] Test: targeted package races passed; `make ci` passed with Go 1.26.5 on darwin/arm64 (gofmt, lint, vet, govulncheck, races, M0 safety, release-tag guide, and binary integration); `make e2e-isolation` passed (forced PostgreSQL RLS/destructive suite plus 50,000-execution scoped-selector fuzz); `make release-check` passed and built reproducible darwin/arm64, darwin/amd64, linux/arm64, and linux/amd64 archives with SBOMs/checksums; `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` passed in 166.178s; `make e2e-ocm` exited 0 and removed its complete M0 lab. A local darwin/arm64 binary smoke served `sith ui` on loopback with HTTP 200, no-store, strict CSP, same-origin isolation, and denied browser permissions. +[A] Red team: reviewed route canonicalization/escaped-path rejection, PEP-before-query ordering, nil dependency failure, exact JSONB membership, RLS workspace isolation, no-match semantics, pagination/limit behavior, stale coverage, raw-report non-retention, and credential-bearing diff preflight. No finding remained. CodeRabbit CLI 0.6.5 was authenticated and accepted the uncommitted diff, but remote review produced no finding or completion after 90 seconds; the invocation was cancelled and is not counted as approval. +[T] Security queue check: Dependabot 0, code scanning 0, secret scanning 0. Existing immutable `v0.2.1` release evidence includes darwin/arm64 archives, SPDX SBOMs, keyless bundles, and provenance; `dev` remains the integration branch for the next beta promotion. +[C] Checkpoint #1: uncommitted implementation validated; next: review README, stage, signed DCO/GSTACK commit, push/open PR into `dev`, require green hosted CI and exact post-merge CI before closing #152. + +--- + +**Session close:** implementation and local evidence ready for commit; hosted review/merge remains. · **Open questions touched:** none. diff --git a/sessions/2026-07-14-e2-vulnerability-report-facts.md b/sessions/2026-07-14-e2-vulnerability-report-facts.md new file mode 100644 index 0000000..3710aa7 --- /dev/null +++ b/sessions/2026-07-14-e2-vulnerability-report-facts.md @@ -0,0 +1,70 @@ +# E2 F2.4 runtime-proven vulnerability report facts + +Issue: [#150](https://github.com/ArdurAI/sith/issues/150) + +Branch: `gnanirahulnutakki/feat/e2-vulnerability-report-facts` + +Base: `origin/dev` at `b903a22bb06fcd6f57ddc785f4aec4bf62a2753e` + +## [G] Goal + +Deliver the next unblocked E2 F2.4 vertical: normalize only pre-existing, Kubernetes-native +`aquasecurity.github.io/v1alpha1` `VulnerabilityReport` records into tenant-scoped CVE facts when +their canonical immutable image digest exactly matches an ordinary Pod container-status digest in +the same spoke snapshot. This deliberately proceeds independently of the unreleased +ClusterGateway transport remediation tracked by #104. + +## [S] Scope + +- Read only one fixed, optional namespaced resource using the registered spoke credential: list + `vulnerabilityreports.aquasecurity.github.io/v1alpha1` with a 100-object, ten-page, 64 KiB + accepted-object budget. +- Persist only canonical image digest, sorted unique CVE IDs, and highest normalized severity in + the existing RLS-protected snapshot store; expose a signed-session, PEP-gated exact-image read + route. +- Prove the path against two real OCM spokes using a static CRD/report fixture and the single + additional least-privilege `list` grant. +- Do not install or execute a scanner; pull images; query a registry, SBOM, or vulnerability feed; + add credentials; retain raw report/package/link/scanner/registry/workload metadata; expose CVE-ID + search; or alter the blocked ClusterGateway transport lane. + +## [A] Design and red-team review + +- A report is evidence only after exact same-snapshot ordinary-Pod runtime digest proof. Tags, + repositories, report-only images, init/ephemeral image status, malformed values, and ambiguous + CVE input abstain. A missing CRD creates no positive CVE fact and no clean-image claim; any + other report-list error fails the snapshot so prior data is marked stale/unreachable. +- The snapshot profile rejects noncanonical identifiers/severities, duplicate normalized JSON keys, + unrelated payload fields, mutable image references, and cross-workspace rows. The exact-image + query is separately PEP-gated and constrained to the `Image` resource kind. +- Manual red-team tracing found one real boundedness gap: empty continuation pages could consume + unbounded list calls. The final implementation hard-caps report pagination at ten calls and has + a regression test proving no eleventh request occurs. +- CodeRabbit CLI 0.6.5 was authenticated and the patch’s secret-like scan was clean. Two + uncommitted-diff reviews against `b903a22` reached only the remote `reviewing` heartbeat and + were cancelled after bounded polling without findings. No review-output instructions were used. + GitHub-side review remains required on the PR. + +## [T] Tests and evidence + +- Focused race suites: `go test -race -count=1 ./internal/hubocm ./internal/hubserver` PASS after + final red-team fixes. +- `make ci`: PASS (gofmt, golangci-lint format/lint, vet, `govulncheck` with no vulnerabilities, + full race/coverage suite, M0 safety and release-tag scripts, binary E2E package gate, build). +- `make e2e-isolation`: PASS; PostgreSQL RLS/destructive isolation plus fixed 50,000-execution + cross-workspace fuzz campaign. Coverage: hubauth 85.2%, hubserver 89.2%, fleetcache 87.0%, + hubdb 73.9%. +- `make release-check`: PASS; reproducible Darwin/Linux amd64/arm64 artifacts, SPDX SBOMs, + checksums, distribution verification, and Homebrew formula rendering. +- `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind`: PASS in 165.716s against two + temporary clusters. +- `make e2e-ocm`: PASS in 168s: real hub plus two spokes, scoped MSA, active hub-to-node/pod deny + controls, static report CRD/fixtures, direct collector, authenticated runtime CVE read, and + cleanup. Konnectivity cancellation messages occurred only during expected teardown. +- `git diff --check`: PASS. Final GitHub queues: Dependabot 0, code scanning 0, secret scanning 0. + +## [C] Checkpoint #1 + +- Final source is ready for signed/DCO checkpoint + `2026-07-14/e2-vulnerability-report-facts#1`; PR, green hosted CI, merge, and exact post-merge + `dev` verification remain pending. diff --git a/sessions/2026-07-14-e9-beta-prerelease.md b/sessions/2026-07-14-e9-beta-prerelease.md new file mode 100644 index 0000000..c5f8651 --- /dev/null +++ b/sessions/2026-07-14-e9-beta-prerelease.md @@ -0,0 +1,63 @@ +# E9 beta prerelease policy — 2026-07-14 + +Issue: #154 +Branch: `gnanirahulnutakki/feat/e9-beta-prerelease` +Base: `origin/dev` at `877f37fb13948eb755da41c3edadd7daa78dd22a` + +## Scope + +Enable exactly one additional release tag form: `vMAJOR.MINOR.PATCH-beta.N`. A beta stays +main-ancestry-only, annotated, GitHub-verified, and signed. It uses the existing archive, SPDX +SBOM, Sigstore, and attestation pipeline, publishes as a GitHub prerelease, and never replaces +the immutable latest stable release. No mutable release, tag rewrite, dev release, alternate +registry, or Homebrew beta channel is introduced. + +## [G] Goal + +Deliver a truthful signed macOS-arm64 beta release without weakening the existing stable-release +boundary. + +## [S] Safety and design checks + +- A repository-owned classifier is the sole tag-shape authority used by the workflow and hermetic + policy test. +- The classifier accepts canonical numeric stable and beta forms only; leading zero components, + non-beta prerelease labels, build metadata, and trailing input are rejected. +- Existing main-ancestry, annotated-tag, and GitHub signature-verification gates remain in the + workflow after classification. +- The beta publication API request explicitly sets both `prerelease=true` and `make_latest=false`; + the stable publication path is unchanged. + +## [A] Evidence + +- Focused policy test: passed accepted stable/beta forms, rejected malformed forms, and asserted + the workflow, the raw-string `make_latest` field, and maintainer-guide constraints. +- Existing release tag identity-guide test: passed all six assertions. +- Final-diff `make ci`: passed formatting, vet, lint, reachable-vulnerability scan, full Go race + suite, shell safety suites, performance check, subprocess E2E, and binary build. +- Final-diff `make release-check`: passed two reproducible four-platform GoReleaser snapshots, + archive/SBOM verification, formula rendering, and digest comparison. +- `make e2e-kind`: passed the real two-cluster fleet and OCI contract tests. The explicitly named + disposable Kind clusters were deleted after the check; no unrelated Docker workload was pruned. +- Manual red-team review found that `gh api -F make_latest=false` would send a boolean even though + the Releases API requires a string enum. The workflow now uses raw `-f make_latest=false`, and + the policy test requires that exact form. +- CodeRabbit accepted the uncommitted diff and reached analysis, but returned no review within the + bounded attempt. Repository status reports that reviews are disabled for the `dev` base branch, + so this is not recorded as an approval. Hosted CI and publication evidence remain pending. + +## [T] Test plan + +1. Run the normal CI and reproducible release-snapshot gates. +2. Red-team the classifier boundary, publication branch, and preservation of signature/ancestry + controls. +3. Land only after green PR and exact post-merge `dev` CI, then make a separate reviewed + `dev -> main` release PR. +4. Create and verify one signed annotated beta tag, its public prerelease state, latest-stable + preservation, macOS-arm64 archive, checksum, provenance, and local binary/UI smoke. + +## [C] Completion criteria + +The issue closes only after the beta artifact has passed the release workflow, its macOS-arm64 +archive has been verified and launched locally, the prior stable remains latest, and security +queues are clean. diff --git a/sessions/2026-07-14-e9-release-pr-ci-gate.md b/sessions/2026-07-14-e9-release-pr-ci-gate.md new file mode 100644 index 0000000..7bb65a8 --- /dev/null +++ b/sessions/2026-07-14-e9-release-pr-ci-gate.md @@ -0,0 +1,47 @@ +# E9 release-PR CI gate — 2026-07-14 + +Issue: #157 +Branch: `gnanirahulnutakki/ci/release-pr-gate` +Base: `origin/dev` at `42d395135b034bcfb531b5657f3eb01b95ea7bb5` + +## [G] Goal + +Require the complete Sith CI workflow for `dev` to `main` release PRs and for the exact resulting +`main` push, so a release boundary has independent build, reproducibility, security, and real +two-cluster evidence. + +## [S] Scope and safety + +- The change expands only the existing CI branch trigger allowlist from `dev` to `dev, main`. +- No job, pin, credential, artifact, release tag, or branch-protection policy is weakened or + removed. +- The maintainer guide requires green release-PR CI and exact post-merge `main` CI before tagging. + +## [A] Evidence + +- The hermetic release-PR policy test confirms CI listens to both push and pull-request events for + `dev` and `main`, and that the guide requires release-PR CI. +- Existing beta-tag policy test remains green. +- Final-diff `make ci` passed formatting, vet, lint, reachable-vulnerability scan, full Go race + suite, all shell policy tests, performance, subprocess E2E, and build. +- Final-diff `make release-check` passed two reproducible four-platform snapshots, archive/SBOM + verification, formula rendering, and digest comparison. +- Manual red-team review confirmed that the only workflow delta is adding `main` to the existing + `push` and `pull_request` branch allowlists. Existing jobs, permissions, pinned actions/tools, + concurrency, and `dev` coverage are unchanged. +- CodeRabbit accepted the uncommitted diff and reached analysis but returned no review in the + bounded attempt; it is not recorded as approval. Hosted PR CI, exact post-merge `dev` CI, and + proof that release PR #156 runs its own full CI after this lands remain pending. + +## [T] Test plan + +1. Run `make ci` and `make release-check` on the final diff. +2. Review that no trigger or workflow permission broadening was introduced and that all existing + job gates remain unchanged. +3. Land only after green PR CI and exact post-merge `dev` CI, then observe a new full CI run on + #156 before promoting `main`. + +## [C] Completion criteria + +#157 closes only after the resulting `dev` to `main` release PR has an independently green full CI +run and a later exact `main` push CI record is available for the beta release commit. diff --git a/sessions/2026-07-14-release-tag-identity.md b/sessions/2026-07-14-release-tag-identity.md new file mode 100644 index 0000000..3595d3a --- /dev/null +++ b/sessions/2026-07-14-release-tag-identity.md @@ -0,0 +1,57 @@ +# Session — 2026-07-14 — release tag identity + +**Issue:** [#146](https://github.com/ArdurAI/sith/issues/146) +**Branch:** `gnanirahulnutakki/docs/release-tag-identity` +**Base:** `origin/dev` at `aa168bf203e9c75815cf460f1754c7d1c3d4881d` + +## [G] Goal + +Document and test the release-tag identity preflight so a locally valid SSH signature cannot be +mistaken for GitHub-verifiable release authorization. + +## [S] Scope + +- Add a privacy-preserving maintainer preflight and post-push GitHub verification command to the + release guide. +- Add a focused documentation-contract test to the existing operator-script gate. +- Keep the release verification gate unchanged; do not alter tags, release assets, or cloud access. + +## [A] Analysis and decision + +- The stable release workflow correctly rejected an annotated tag whose local SSH signature was + valid but whose tagger identity was not recognized by GitHub. No draft or public artifacts were + created for that rejected tag. +- The recovery used a new immutable patch tag on the same reviewed commit with an account identity + GitHub verified. The resulting release passed its signature, provenance, checksum, and SBOM + checks. +- The guide now separates local signature verification from GitHub's tag-object verification and + explicitly prohibits deleting, force-pushing, or retagging a published name. +- Red-team review found that a direct paginated-API-to-`grep -q` pipeline could surface a producer + SIGPIPE under `pipefail`. The guide captures the verified-email list before the exact match. +- A second review identified that verified email alone cannot bind the configured local SSH key to + the GitHub account. The preflight now compares normalized public key material against the + account's public signing-key endpoint without requesting broader token scope or printing values. + +## [T] Tests and evidence + +- PASS — `bash -n tests/scripts/release_tag_identity_guide_test.sh` and the focused guide + contract suite; its six assertions cover email and SSH-key identity preflight, local tag + verification, GitHub tag-object verification, no-rewrite rule, and patch-version recovery. +- PASS — `make ci`, including formatting, vet, static analysis with zero findings, dependency + vulnerability scanning with no findings, race tests, the existing 19 M0 safety assertions, the + new guide-contract assertions, latency budget, and tagged binary e2e. +- PASS — `make e2e-isolation`: PostgreSQL forced-RLS/destructive coverage plus the fixed + 50,000-execution workspace fuzz campaign. +- PASS — `make release-check`: two complete four-platform snapshot builds, archive digest + comparison, SPDX SBOM generation, and formula rendering. +- PASS — final `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-kind` in 163.851 seconds. +- Red-team review: fixed the `pipefail`/SIGPIPE preflight issue and the missing registered + signing-key check. A final reviewer retry became nonresponsive after a bounded wait; manual + staged-diff, whitespace, focused-contract, and live public-key-match checks found no remaining + issue. +- Pending signed commit, pull-request CI, and exact post-merge verification. + +## [C] Checkpoint #1 + +- Pending signed commit. Open questions touched: none; the existing immutable-release and + GitHub-verification defaults remain unchanged. diff --git a/tests/e2e/kind_read_federation_test.go b/tests/e2e/kind_read_federation_test.go index 7ef5497..3c13df8 100644 --- a/tests/e2e/kind_read_federation_test.go +++ b/tests/e2e/kind_read_federation_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "sort" "sync" "testing" @@ -88,6 +89,11 @@ func exerciseReadFederationSnapshots( staleCorrelation.Coverage.Stale[0] != "spoke-b" { t.Fatalf("stale real two-spoke correlation = %#v, error = %v", staleCorrelation, err) } + assessment := staleCorrelation.Coverage.Assessment() + if assessment.Complete || !slices.Equal(assessment.Gaps, []fleet.CoverageGap{fleet.CoverageGapUnreachable, fleet.CoverageGapStale}) || + !slices.Equal(assessment.Unreachable, []string{"spoke-b"}) || !slices.Equal(assessment.Stale, []string{"spoke-b"}) { + t.Fatalf("stale real two-spoke assessment = %#v", assessment) + } } func e2eReadPEP(t *testing.T) *pep.Enforcer { diff --git a/tests/scripts/release_pr_gate_policy_test.sh b/tests/scripts/release_pr_gate_policy_test.sh new file mode 100755 index 0000000..56d91df --- /dev/null +++ b/tests/scripts/release_pr_gate_policy_test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +workflow="${repo_root}/.github/workflows/ci.yml" +guide="${repo_root}/docs/RELEASE.md" + +if ! awk ' + /^ push:/ { section = "push"; next } + /^ pull_request:/ { section = "pull_request"; next } + /^ [A-Za-z_]/ { section = "" } + section == "push" && /branches: \[dev, main\]/ { push_ok = 1 } + section == "pull_request" && /branches: \[dev, main\]/ { pull_request_ok = 1 } + END { exit !(push_ok && pull_request_ok) } +' "${workflow}"; then + printf '[release-pr-gate] FAIL: ci must run on dev and main pushes and pull requests\n' >&2 + exit 1 +fi +printf '[release-pr-gate] PASS: ci runs on dev and main pushes and pull requests\n' + +if ! grep -Fq 'release PR before merging it' "${guide}"; then + printf '[release-pr-gate] FAIL: release guide does not require release-PR CI\n' >&2 + exit 1 +fi +printf '[release-pr-gate] PASS: release guide requires release-PR CI\n' diff --git a/tests/scripts/release_tag_identity_guide_test.sh b/tests/scripts/release_tag_identity_guide_test.sh new file mode 100644 index 0000000..d24be11 --- /dev/null +++ b/tests/scripts/release_tag_identity_guide_test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +guide="${repo_root}/docs/RELEASE.md" + +assert_contains() { + local needle="$1" + local description="$2" + + if ! grep -Fq -- "$needle" "$guide"; then + printf '[release-guide] FAIL: %s\n' "$description" >&2 + exit 1 + fi + + printf '[release-guide] PASS: %s\n' "$description" +} + +assert_contains 'gh api user/emails --paginate' \ + 'requires a GitHub-recognized tagger identity before tagging' +assert_contains 'ssh_signing_keys' \ + 'requires the configured SSH signing key to match GitHub registration' +assert_contains 'git tag -v "$tag"' \ + 'requires local annotated-tag signature verification' +assert_contains '.verification.verified' \ + 'requires GitHub tag-object verification after push' +assert_contains 'do not delete, force-push, or retag the published name' \ + 'forbids rewriting a rejected release tag' +assert_contains 'cut a new patch version only after the identity issue is fixed' \ + 'requires immutable patch-version recovery' + +printf '[release-guide] 6 assertions passed\n' diff --git a/tests/scripts/release_tag_policy_test.sh b/tests/scripts/release_tag_policy_test.sh new file mode 100755 index 0000000..8d03a7c --- /dev/null +++ b/tests/scripts/release_tag_policy_test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +classifier="${repo_root}/hack/release-tag-classify.sh" +workflow="${repo_root}/.github/workflows/release.yml" +guide="${repo_root}/docs/RELEASE.md" + +expect_class() { + local tag="$1" + local expected="$2" + local actual + + actual="$("${classifier}" "${tag}")" + if [[ "${actual}" != "${expected}" ]]; then + printf '[release-tag-policy] FAIL: %s classified as %q, want %q\n' "${tag}" "${actual}" "${expected}" >&2 + exit 1 + fi + printf '[release-tag-policy] PASS: %s classified as %s\n' "${tag}" "${expected}" +} + +expect_rejection() { + local tag="$1" + + if "${classifier}" "${tag}" >/dev/null 2>&1; then + printf '[release-tag-policy] FAIL: unsafe tag %s was accepted\n' "${tag}" >&2 + exit 1 + fi + printf '[release-tag-policy] PASS: unsafe tag %s rejected\n' "${tag}" +} + +expect_class 'v0.3.0' stable +expect_class 'v0.3.0-beta.1' beta +expect_class 'v12.34.56-beta.0' beta + +for tag in \ + 'v00.3.0' \ + 'v0.03.0' \ + 'v0.3.00' \ + 'v0.3.0-beta.01' \ + 'v0.3.0-beta' \ + 'v0.3.0-rc.1' \ + 'v0.3.0-beta.1+build.1' \ + 'v0.3.0-beta.1.2' \ + 'v0.3.0 ' \ + 'release-v0.3.0'; do + expect_rejection "${tag}" +done + +grep -Fq 'hack/release-tag-classify.sh "$GITHUB_REF_NAME"' "${workflow}" +printf '[release-tag-policy] PASS: workflow invokes the tested classifier\n' +grep -Fq -- '-f make_latest=false' "${workflow}" +printf '[release-tag-policy] PASS: beta publication cannot replace latest stable\n' +grep -Fq 'vMAJOR.MINOR.PATCH-beta.N' "${guide}" +printf '[release-tag-policy] PASS: guide documents the beta channel\n'