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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: ci

on:
push:
branches: [dev]
branches: [dev, main]
pull_request:
branches: [dev]
branches: [dev, main]

permissions:
contents: read
Expand Down
20 changes: 14 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand All @@ -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
Expand Down
76 changes: 67 additions & 9 deletions docs/RELEASE.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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"
Comment on lines +128 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e

# Inspect the relevant section of docs/RELEASE.md with line numbers.
wc -l docs/RELEASE.md
sed -n '110,155p' docs/RELEASE.md | cat -n

Repository: ArdurAI/sith

Length of output: 3559


🏁 Script executed:

#!/bin/bash
set -e

# Read the immediate surrounding context to see whether the block is wrapped
# in error handling or only shown as a standalone preflight.
sed -n '120,150p' docs/RELEASE.md

Repository: ArdurAI/sith

Length of output: 2213


Make this snippet fail closed. Add set -euo pipefail or explicit || exit 1 checks so a failed test/grep cannot fall through and report success on invalid local identity config.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/RELEASE.md` around lines 127 - 140, Make the shell snippet fail closed
by enabling strict error handling with set -euo pipefail near the start, or
explicitly exiting on failure for every test and grep command. Ensure failures
in identity validation, email lookup, and signing-key verification stop
execution rather than allowing the snippet to report success.

```

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
Expand Down
6 changes: 4 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
69 changes: 69 additions & 0 deletions hack/experiments/m0-ocm-falsification.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 - <<EOF
apiVersion: aquasecurity.github.io/v1alpha1
kind: VulnerabilityReport
metadata:
name: fixture-runtime-image
namespace: sith-demo
report:
artifact:
digest: ${digest}
repository: ignored.example/fixture
tag: mutable-and-ignored
scanner:
name: ignored
vendor: ignored
version: ignored
vulnerabilities:
- vulnerabilityID: CVE-2026-0001
severity: HIGH
description: not-retained
- vulnerabilityID: CVE-2026-0002
severity: MEDIUM
links:
- https://ignored.invalid/not-retained
EOF
}

create_scoped_identity() {
local cluster=$1
local context=$2
Expand Down Expand Up @@ -666,6 +729,9 @@ rules:
- apiGroups: ["argoproj.io"]
resources: ["rollouts"]
verbs: ["list"]
- apiGroups: ["aquasecurity.github.io"]
resources: ["vulnerabilityreports"]
verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
Expand Down Expand Up @@ -892,6 +958,9 @@ run_lab() {
build_fixture
deploy_fixture spoke-a "${SPOKE_A_CONTEXT}"
deploy_fixture spoke-b "${SPOKE_B_CONTEXT}"

install_vulnerability_report_fixture "${SPOKE_A_CONTEXT}"
install_vulnerability_report_fixture "${SPOKE_B_CONTEXT}"
create_scoped_identity spoke-a "${SPOKE_A_CONTEXT}"
create_scoped_identity spoke-b "${SPOKE_B_CONTEXT}"
verify_lab
Expand Down
24 changes: 24 additions & 0 deletions hack/release-tag-classify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash

set -euo pipefail

if [[ "$#" -ne 1 ]]; then
echo "usage: release-tag-classify.sh <tag>" >&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
Loading