From 2b0ca7c69a2704c607d78f1562864d895604aee9 Mon Sep 17 00:00:00 2001 From: BrianCLong <6404035+BrianCLong@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:23:45 -0600 Subject: [PATCH] feat: add admissibility gate across CI and cluster enforcement --- .github/workflows/admissibility-gate.yml | 96 +++++++++++++++++++ k8s/argo/admissibility-presync-check.yaml | 33 +++++++ k8s/base/kustomization.yaml | 2 + .../kyverno-admissibility-enforcement.yaml | 59 ++++++++++++ k8s/server-deployment.yaml | 9 +- lib/admissibility.ts | 96 +++++++++++++++++++ package.json | 4 +- scripts/ci/build_admissibility_evidence.mjs | 75 +++++++++++++++ scripts/ci/verify-admissibility.ts | 17 ++++ tests/ci/admissibility.test.mts | 32 +++++++ 10 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/admissibility-gate.yml create mode 100644 k8s/argo/admissibility-presync-check.yaml create mode 100644 k8s/policies/kyverno-admissibility-enforcement.yaml create mode 100644 lib/admissibility.ts create mode 100644 scripts/ci/build_admissibility_evidence.mjs create mode 100644 scripts/ci/verify-admissibility.ts create mode 100644 tests/ci/admissibility.test.mts diff --git a/.github/workflows/admissibility-gate.yml b/.github/workflows/admissibility-gate.yml new file mode 100644 index 00000000000..4693c3a16cc --- /dev/null +++ b/.github/workflows/admissibility-gate.yml @@ -0,0 +1,96 @@ +name: Admissibility Gate + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + id-token: write + attestations: write + +jobs: + evidence-admissibility: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Syft + uses: anchore/sbom-action/download-syft@v0.17.0 + + - name: Install Cosign + uses: sigstore/cosign-installer@v3.8.1 + + - name: Build deterministic artifact payload + run: | + mkdir -p dist + sha256sum package.json pnpm-lock.yaml | awk '{print $1}' | sort > dist/admissible-artifact.txt + + - name: Generate SBOM (CycloneDX) + run: syft . -o cyclonedx-json=evidence/sbom.cdx.json + + - name: Assert SBOM completeness + run: | + test -f evidence/sbom.cdx.json + jq -e '.components and (.components | length > 0)' evidence/sbom.cdx.json + + - name: Generate provenance attestation (SLSA) + uses: actions/attest-build-provenance@v3 + with: + subject-path: dist/admissible-artifact.txt + + - name: Materialize deterministic provenance snapshot + run: | + DIGEST="$(sha256sum dist/admissible-artifact.txt | awk '{print $1}')" + jq -n \ + --arg digest "sha256:${DIGEST}" \ + --arg repo "${{ github.repository }}" \ + '{ + _type: "https://in-toto.io/Statement/v1", + predicateType: "https://slsa.dev/provenance/v1", + subject: [{name: "dist/admissible-artifact.txt", digest: {sha256: ($digest | sub("^sha256:"; ""))}}], + builder: {id: "https://github.com/actions/runner"}, + invocation: {configSource: {uri: $repo}} + }' > evidence/provenance.json + + - name: Sign and verify artifact signature + run: | + cosign generate-key-pair + cosign sign-blob --yes --key cosign.key --output-signature evidence/artifact.sig dist/admissible-artifact.txt + cosign verify-blob --key cosign.pub --signature evidence/artifact.sig dist/admissible-artifact.txt + + - name: Build evidence report/metrics/stamp + env: + ARTIFACT_PATH: dist/admissible-artifact.txt + SBOM_PATH: evidence/sbom.cdx.json + PROVENANCE_PATH: evidence/provenance.json + SIGNATURE_VERIFIED: "true" + run: node scripts/ci/build_admissibility_evidence.mjs + + - name: Evaluate admissibility gate + run: pnpm verify:admissibility --input evidence/report.json + + - name: Upload evidence artifacts + uses: actions/upload-artifact@v4 + with: + name: admissibility-evidence-${{ github.run_id }} + path: | + evidence/report.json + evidence/metrics.json + evidence/stamp.json + evidence/sbom.cdx.json + evidence/provenance.json + evidence/artifact.sig + cosign.pub diff --git a/k8s/argo/admissibility-presync-check.yaml b/k8s/argo/admissibility-presync-check.yaml new file mode 100644 index 00000000000..801ef080c84 --- /dev/null +++ b/k8s/argo/admissibility-presync-check.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: admissibility-status + namespace: argocd +data: + status: "PASS" +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: admissibility-presync-check + namespace: argocd + annotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook-delete-policy: HookSucceeded +spec: + template: + spec: + restartPolicy: Never + containers: + - name: gate + image: bitnami/kubectl:1.30 + command: + - /bin/sh + - -ec + - | + STATUS="$(kubectl -n argocd get configmap admissibility-status -o jsonpath='{.data.status}')" + if [ "$STATUS" != "PASS" ]; then + echo "ArgoCD sync blocked: admissibility status is $STATUS" + exit 1 + fi + echo "ArgoCD sync permitted: admissibility status is PASS" diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml index 27cff9bf44c..2010f2bbddf 100644 --- a/k8s/base/kustomization.yaml +++ b/k8s/base/kustomization.yaml @@ -7,3 +7,5 @@ resources: - ../crd/vex-override-verification.yaml - ../crd/vex-override-ledger.yaml - tsa-cert-chain-config.yaml + - ../policies/kyverno-admissibility-enforcement.yaml + - ../argo/admissibility-presync-check.yaml diff --git a/k8s/policies/kyverno-admissibility-enforcement.yaml b/k8s/policies/kyverno-admissibility-enforcement.yaml new file mode 100644 index 00000000000..83dadaa12e6 --- /dev/null +++ b/k8s/policies/kyverno-admissibility-enforcement.yaml @@ -0,0 +1,59 @@ +apiVersion: kyverno.io/v1 +kind: ClusterPolicy +metadata: + name: enforce-admissibility-supply-chain + annotations: + policies.kyverno.io/title: Enforce Admissibility for Deployments + policies.kyverno.io/category: Supply Chain Security + policies.kyverno.io/severity: high +spec: + validationFailureAction: Enforce + background: true + rules: + - name: require-admissibility-pass-label + match: + any: + - resources: + kinds: + - Pod + validate: + message: "security.summit.io/admissibility label must be PASS" + pattern: + metadata: + labels: + security.summit.io/admissibility: "PASS" + + - name: require-signed-images + match: + any: + - resources: + kinds: + - Pod + verifyImages: + - imageReferences: + - "*" + mutateDigest: true + required: true + attestors: + - count: 1 + entries: + - keyless: + issuer: https://token.actions.githubusercontent.com + subject: https://github.com/*/* + + - name: require-slsa-attestation + match: + any: + - resources: + kinds: + - Pod + verifyImages: + - imageReferences: + - "*" + attestations: + - type: slsaprovenance + conditions: + all: + - key: "{{ payload.predicateType }}" + operator: Equals + value: https://slsa.dev/provenance/v1 diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml index 72e21b0bb3f..1bb1bcc25ce 100644 --- a/k8s/server-deployment.yaml +++ b/k8s/server-deployment.yaml @@ -10,9 +10,10 @@ spec: metadata: labels: app: summit-server + security.summit.io/admissibility: "PASS" spec: containers: - - name: summit-server - image: summit/server - ports: - - containerPort: 4000 + - name: summit-server + image: summit/server + ports: + - containerPort: 4000 diff --git a/lib/admissibility.ts b/lib/admissibility.ts new file mode 100644 index 00000000000..82dc998eb87 --- /dev/null +++ b/lib/admissibility.ts @@ -0,0 +1,96 @@ +export type AdmissibilityStatus = "PASS" | "FAIL"; + +export interface SignatureEvidence { + valid: boolean; + keyId?: string; +} + +export interface SbomEvidence { + present: boolean; + complete: boolean; + format?: string; + components?: string[]; +} + +export interface ProvenanceEvidence { + present: boolean; + chainIntact: boolean; + builderId?: string; + subjects?: string[]; +} + +export interface DependencyEvidence { + prohibitedFound: string[]; +} + +export interface AdmissibilityEvidenceBundle { + artifact: { + digest: string; + name?: string; + }; + signature: SignatureEvidence; + sbom: SbomEvidence; + provenance: ProvenanceEvidence; + dependencies: DependencyEvidence; +} + +export interface AdmissibilityVerdict { + status: AdmissibilityStatus; + reasons: string[]; + checks: { + signatureValid: boolean; + sbomComplete: boolean; + provenanceIntact: boolean; + prohibitedDependencies: boolean; + }; + artifact: { + digest: string; + name?: string; + }; +} + +const sortUnique = (values: string[]): string[] => + Array.from(new Set(values)).sort((a, b) => a.localeCompare(b)); + +export function evaluateAdmissibility( + evidenceBundle: AdmissibilityEvidenceBundle +): AdmissibilityVerdict { + const reasons: string[] = []; + + const signatureValid = evidenceBundle.signature.valid; + if (!signatureValid) { + reasons.push("INVALID_SIGNATURE"); + } + + const sbomComplete = evidenceBundle.sbom.present && evidenceBundle.sbom.complete; + if (!sbomComplete) { + reasons.push("MISSING_OR_INCOMPLETE_SBOM"); + } + + const provenanceIntact = + evidenceBundle.provenance.present && evidenceBundle.provenance.chainIntact; + if (!provenanceIntact) { + reasons.push("BROKEN_PROVENANCE_CHAIN"); + } + + const prohibitedDependencies = evidenceBundle.dependencies.prohibitedFound.length === 0; + if (!prohibitedDependencies) { + const blocked = sortUnique(evidenceBundle.dependencies.prohibitedFound).join(","); + reasons.push(`PROHIBITED_DEPENDENCIES:${blocked}`); + } + + return { + status: reasons.length === 0 ? "PASS" : "FAIL", + reasons, + checks: { + signatureValid, + sbomComplete, + provenanceIntact, + prohibitedDependencies, + }, + artifact: { + digest: evidenceBundle.artifact.digest, + name: evidenceBundle.artifact.name, + }, + }; +} diff --git a/package.json b/package.json index 70d03b900f1..498cb8b81f5 100644 --- a/package.json +++ b/package.json @@ -199,7 +199,9 @@ "ga:certify": "npx tsx scripts/benchmarks/run_perf.ts && npx tsx scripts/ga-validator.ts", "ga:sentinel": "node scripts/drift-sentinel.mjs", "ga:report": "npx tsx scripts/generate-compliance-report.ts", - "ga:finish": "rm -rf scripts/ga-final-walkthrough.sh scripts/failure-injector.mjs signals/ *.bak && echo '🏁 RELEASE SEALTED. GA PILOT LIVE.' " + "ga:finish": "rm -rf scripts/ga-final-walkthrough.sh scripts/failure-injector.mjs signals/ *.bak && echo '🏁 RELEASE SEALTED. GA PILOT LIVE.' ", + "verify:admissibility": "npx tsx scripts/ci/verify-admissibility.ts", + "test:admissibility": "node --test --import tsx tests/ci/admissibility.test.mts" }, "keywords": [ "intelligence-analysis", diff --git a/scripts/ci/build_admissibility_evidence.mjs b/scripts/ci/build_admissibility_evidence.mjs new file mode 100644 index 00000000000..d1489412f91 --- /dev/null +++ b/scripts/ci/build_admissibility_evidence.mjs @@ -0,0 +1,75 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + +const artifactPath = process.env.ARTIFACT_PATH ?? 'dist/admissible-artifact.txt'; +const sbomPath = process.env.SBOM_PATH ?? 'evidence/sbom.cdx.json'; +const provenancePath = process.env.PROVENANCE_PATH ?? 'evidence/provenance.json'; +const signatureVerified = process.env.SIGNATURE_VERIFIED === 'true'; +const prohibitedDeps = (process.env.PROHIBITED_DEPS ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + +const artifactDigest = createHash('sha256') + .update(readFileSync(artifactPath)) + .digest('hex'); + +const sbom = JSON.parse(readFileSync(sbomPath, 'utf8')); +const provenance = JSON.parse(readFileSync(provenancePath, 'utf8')); + +const components = (sbom.components ?? []) + .map((c) => c?.name) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + +const report = { + artifact: { + name: artifactPath, + digest: `sha256:${artifactDigest}`, + }, + signature: { + valid: signatureVerified, + keyId: 'cosign.pub', + }, + sbom: { + present: Boolean(sbom.bomFormat), + complete: components.length > 0, + format: sbom.bomFormat ?? 'unknown', + components, + }, + provenance: { + present: Boolean(provenance.predicateType), + chainIntact: Boolean(provenance.subject?.length && provenance.builder?.id), + builderId: provenance.builder?.id, + subjects: (provenance.subject ?? []) + .map((subject) => subject?.name) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)), + }, + dependencies: { + prohibitedFound: prohibitedDeps, + }, +}; + +const metrics = { + checks_total: 4, + checks_passed: + Number(report.signature.valid) + + Number(report.sbom.present && report.sbom.complete) + + Number(report.provenance.present && report.provenance.chainIntact) + + Number(report.dependencies.prohibitedFound.length === 0), + prohibited_dependency_count: report.dependencies.prohibitedFound.length, + sbom_component_count: report.sbom.components.length, +}; + +const stamp = { + run_id: process.env.GITHUB_RUN_ID ?? 'local', + git_sha: process.env.GITHUB_SHA ?? 'local', + generated_at: new Date().toISOString(), +}; + +mkdirSync('evidence', { recursive: true }); +writeFileSync('evidence/report.json', `${JSON.stringify(report, null, 2)}\n`); +writeFileSync('evidence/metrics.json', `${JSON.stringify(metrics, null, 2)}\n`); +writeFileSync('evidence/stamp.json', `${JSON.stringify(stamp, null, 2)}\n`); diff --git a/scripts/ci/verify-admissibility.ts b/scripts/ci/verify-admissibility.ts new file mode 100644 index 00000000000..7c7c61d4e48 --- /dev/null +++ b/scripts/ci/verify-admissibility.ts @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { evaluateAdmissibility, type AdmissibilityEvidenceBundle } from "../../lib/admissibility"; + +const args = process.argv.slice(2); +const inputFlagIndex = args.indexOf("--input"); +const inputPath = inputFlagIndex >= 0 ? args[inputFlagIndex + 1] : "evidence/report.json"; + +const reportPath = resolve(process.cwd(), inputPath); +const parsed = JSON.parse(readFileSync(reportPath, "utf8")) as AdmissibilityEvidenceBundle; +const verdict = evaluateAdmissibility(parsed); + +process.stdout.write(`${JSON.stringify(verdict, null, 2)}\n`); + +if (verdict.status !== "PASS") { + process.exit(1); +} diff --git a/tests/ci/admissibility.test.mts b/tests/ci/admissibility.test.mts new file mode 100644 index 00000000000..8105ab47651 --- /dev/null +++ b/tests/ci/admissibility.test.mts @@ -0,0 +1,32 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { evaluateAdmissibility } from "../../lib/admissibility"; + +test("evaluateAdmissibility returns PASS for complete valid bundle", () => { + const verdict = evaluateAdmissibility({ + artifact: { digest: "sha256:abc" }, + signature: { valid: true }, + sbom: { present: true, complete: true, components: ["a"] }, + provenance: { present: true, chainIntact: true }, + dependencies: { prohibitedFound: [] }, + }); + + assert.equal(verdict.status, "PASS"); + assert.deepEqual(verdict.reasons, []); +}); + +test("evaluateAdmissibility fails when mandatory checks fail", () => { + const verdict = evaluateAdmissibility({ + artifact: { digest: "sha256:def" }, + signature: { valid: false }, + sbom: { present: true, complete: false, components: [] }, + provenance: { present: true, chainIntact: false }, + dependencies: { prohibitedFound: ["left-pad"] }, + }); + + assert.equal(verdict.status, "FAIL"); + assert.ok(verdict.reasons.includes("INVALID_SIGNATURE")); + assert.ok(verdict.reasons.includes("MISSING_OR_INCOMPLETE_SBOM")); + assert.ok(verdict.reasons.includes("BROKEN_PROVENANCE_CHAIN")); + assert.ok(verdict.reasons.includes("PROHIBITED_DEPENDENCIES:left-pad")); +});