diff --git a/.github/workflows/qgate.yml b/.github/workflows/qgate.yml index 27b95802..7393f578 100644 --- a/.github/workflows/qgate.yml +++ b/.github/workflows/qgate.yml @@ -62,7 +62,7 @@ jobs: retention-days: 14 - name: Install cargo-cyclonedx - run: cargo install cargo-cyclonedx --locked + run: cargo install cargo-cyclonedx --version 0.5.9 --locked - name: Generate CycloneDX SBOM run: | @@ -72,6 +72,10 @@ jobs: echo "SBOM files:" find . -name '*.cdx.json' -print + - name: Validate CycloneDX SBOM anchors + shell: pwsh + run: ./scripts/sbom-validate-check.ps1 -Path target/sbom.cdx.json + - name: Upload CycloneDX SBOM uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc4b1051..855de6cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -436,7 +436,7 @@ jobs: cat SHA256SUMS - name: install cargo-cyclonedx - run: cargo install cargo-cyclonedx --locked + run: cargo install cargo-cyclonedx --version 0.5.9 --locked - name: create CycloneDX SBOM run: | @@ -449,6 +449,10 @@ jobs: -printf '%P\n' | sort | xargs -r sha256sum > SHA256SUMS ) + - name: validate release CycloneDX SBOM anchors + shell: pwsh + run: ./scripts/sbom-validate-check.ps1 -Path artifacts/session-ledger.cdx.json + - name: attest Release assets id: provenance if: github.repository == 'KooshaPari/SessionLedger' diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index fd457ce4..833a4248 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -131,6 +131,16 @@ jobs: shell: pwsh run: ./scripts/cargo-nonet-check.ps1 -SelfCheck + # Blocking C04 L32 SBOM policy evidence — hermetic SelfCheck only. + sbom-policy: + name: SBOM policy SelfCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - name: SBOM policy + CycloneDX anchor SelfCheck + shell: pwsh + run: ./scripts/sbom-validate-check.ps1 -SelfCheck + # Hard C02 L22 envelope-crypto CI evidence — blocking PR workflow in # .github/workflows/envelope-crypto.yml (envelope-crypto-check.ps1 -SelfCheck). # Does not claim in-tree KMS, sealed secrets, KEK wrap, or OKF/audit encryption. diff --git a/CHANGELOG.md b/CHANGELOG.md index ad516f5a..971ba7ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( ### Added +- SBOM schema validation + pinned cargo-cyclonedx (C04 L32): `docs/ops/sbom-policy.json`, `scripts/sbom-validate-check.ps1 -SelfCheck`, post-generation validation in `qgate.yml`/`release.yml`, blocking `security.yml` SBOM policy job, `tests/sbom_validate.rs`. + - Wave-42 scope (396/402): consolidated `WAVE42_SCOPE.md` + `docs/ops/WAVE42_PERT.md` — five parallel carry-forward lanes (`w42-signing-check-bound`, `w42-sbom-validate`, `w42-slsa-promote`, `w42-alloc-gate-promote`, `w42-first-run-cta`) from Wave-41 deferred gaps. - P95 baseline refresh (C00 L6 / C08 L74): `bench-gate.ps1 -UpdateBaseline` writes `p95_source` per benchmark; `perf-baseline.json` refreshed from Criterion `sample.json` (replaces provisional mean×1.15 values). diff --git a/SECURITY.md b/SECURITY.md index f8f0b282..c490f4c3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -66,6 +66,7 @@ Critical supply-chain or remote-code issues may be accelerated at maintainer dis - Secret scanning runs with gitleaks and TruffleHog (dual-scan) in the same workflow on PRs and pushes to `main` (plus a weekly scheduled full scan). Gitleaks uploads SARIF to GitHub code scanning when `security-events` write is available. Local pre-commit hooks use gitleaks only. - Dependency updates: [`renovate.json`](renovate.json) groups Cargo and GitHub Actions PRs and automerges **patch** (and Actions digest) updates after required CI checks pass. Weekly Dependabot ([`.github/dependabot.yml`](.github/dependabot.yml)) remains as a secondary CVE/update surface until Renovate is the sole bot. Prefer reviewing Renovate majors manually. - CycloneDX SBOMs are produced in the qgate path as `target/sbom.cdx.json` and per-crate `*.cdx.json` artifacts (see [`.github/workflows/qgate.yml`](.github/workflows/qgate.yml) header comments). Packaging notes: [`packaging/README.md`](packaging/README.md). +- SBOM policy SSOT: [`docs/ops/sbom-policy.json`](docs/ops/sbom-policy.json) pins `cargo-cyclonedx` and documents validation via [`scripts/sbom-validate-check.ps1`](scripts/sbom-validate-check.ps1). - Advisory scanning: `cargo audit` job in .github/workflows/security.yml. - SBOM upload: qgate uploads `sbom-cyclonedx` artifact from `target/sbom.cdx.json`. diff --git a/docs/ops/fixtures/sbom-valid-minimal.cdx.json b/docs/ops/fixtures/sbom-valid-minimal.cdx.json new file mode 100644 index 00000000..4ad18f0a --- /dev/null +++ b/docs/ops/fixtures/sbom-valid-minimal.cdx.json @@ -0,0 +1,20 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "component": { + "type": "application", + "name": "session-ledger-fixture", + "version": "0.0.0-selfcheck" + } + }, + "components": [ + { + "type": "library", + "name": "serde", + "version": "1.0.0", + "purl": "pkg:cargo/serde@1.0.0" + } + ] +} diff --git a/docs/ops/sbom-policy.json b/docs/ops/sbom-policy.json new file mode 100644 index 00000000..04f85cc8 --- /dev/null +++ b/docs/ops/sbom-policy.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "purpose": "Pinned cargo-cyclonedx installer + CycloneDX SBOM validation policy (C04 L32)", + "cargo_cyclonedx_version": "0.5.9", + "cargo_install_command": "cargo install cargo-cyclonedx --version 0.5.9 --locked", + "workspace_sbom_path": "target/sbom.cdx.json", + "release_sbom_path": "artifacts/session-ledger.cdx.json", + "required_fields": [ + "bomFormat", + "specVersion", + "version", + "metadata" + ], + "verify_command": "./scripts/sbom-validate-check.ps1 -SelfCheck", + "validate_command": "./scripts/sbom-validate-check.ps1 -Path target/sbom.cdx.json", + "update_policy": "Bump cargo_cyclonedx_version and workflow install lines together; refresh fixtures if specVersion changes." +} diff --git a/scripts/sbom-validate-check.ps1 b/scripts/sbom-validate-check.ps1 new file mode 100644 index 00000000..6fd0045f --- /dev/null +++ b/scripts/sbom-validate-check.ps1 @@ -0,0 +1,181 @@ +<# +.SYNOPSIS + Validate CycloneDX SBOM policy anchors and optional SBOM JSON files. + +.DESCRIPTION + SelfCheck verifies docs/ops/sbom-policy.json, pinned cargo-cyclonedx install + lines in qgate.yml + release.yml, and classification fixtures. -Path mode + validates bomFormat/specVersion/metadata anchors on a generated .cdx.json. + +.PARAMETER SelfCheck + Hermetic policy + fixture smoke (no cargo-cyclonedx required). + +.PARAMETER Path + CycloneDX JSON file to validate (post-generation in CI). + +.EXAMPLE + pwsh ./scripts/sbom-validate-check.ps1 -SelfCheck + pwsh ./scripts/sbom-validate-check.ps1 -Path target/sbom.cdx.json +#> +[CmdletBinding()] +param( + [switch]$SelfCheck, + [string]$Path = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$policyPath = Join-Path $repoRoot "docs/ops/sbom-policy.json" +$validFixture = Join-Path $repoRoot "docs/ops/fixtures/sbom-valid-minimal.cdx.json" +$qgateWorkflow = Join-Path $repoRoot ".github/workflows/qgate.yml" +$releaseWorkflow = Join-Path $repoRoot ".github/workflows/release.yml" +$securityWorkflow = Join-Path $repoRoot ".github/workflows/security.yml" +$securityDoc = Join-Path $repoRoot "SECURITY.md" +$selfPath = Join-Path $repoRoot "scripts/sbom-validate-check.ps1" +$wrapperTest = Join-Path $repoRoot "tests/sbom_validate.rs" + +function Assert-File { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(Mandatory = $true)][string]$Label + ) + if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { + throw "Missing $Label at '$FilePath'." + } +} + +function Write-Check { + param([string]$Label, [bool]$Ok) + $mark = if ($Ok) { "PASS" } else { "FAIL" } + Write-Host " [$mark] $Label" + return $Ok +} + +function Assert-Contains { + param( + [Parameter(Mandatory = $true)][string]$Doc, + [Parameter(Mandatory = $true)][string]$Needle, + [Parameter(Mandatory = $true)][string]$Label, + [string]$Context = "document" + ) + $ok = $Doc.Contains($Needle) + [void](Write-Check -Label $Label -Ok $ok) + if (-not $ok) { + throw "$Context missing required anchor: '$Needle'" + } +} + +function Test-CycloneDxSbom { + param( + [Parameter(Mandatory = $true)][string]$FilePath + ) + + Assert-File -FilePath $FilePath -Label "CycloneDX SBOM" + $raw = Get-Content -LiteralPath $FilePath -Raw + if ([string]::IsNullOrWhiteSpace($raw)) { + throw "SBOM at '$FilePath' is empty." + } + + try { + $bom = $raw | ConvertFrom-Json + } + catch { + throw "SBOM at '$FilePath' is not valid JSON: $($_.Exception.Message)" + } + + if ($bom.bomFormat -ne "CycloneDX") { + throw "SBOM at '$FilePath' bomFormat must be CycloneDX (got '$($bom.bomFormat)')." + } + if ($bom.specVersion -notmatch '^1\.[0-9]+$') { + throw "SBOM at '$FilePath' specVersion must be CycloneDX 1.x (got '$($bom.specVersion)')." + } + if ($null -eq $bom.version -or [int]$bom.version -lt 1) { + throw "SBOM at '$FilePath' version must be a positive integer." + } + if ($null -eq $bom.metadata) { + throw "SBOM at '$FilePath' missing metadata object." + } + if ($null -eq $bom.metadata.component -or [string]::IsNullOrWhiteSpace([string]$bom.metadata.component.name)) { + throw "SBOM at '$FilePath' missing metadata.component.name." + } + if ($null -eq $bom.components) { + throw "SBOM at '$FilePath' missing components array." + } + if ($bom.components -isnot [System.Array]) { + throw "SBOM at '$FilePath' components must be an array." + } + + return $true +} + +function Invoke-SelfCheck { + Write-Host "SBOM policy check (C04 L32)" + Write-Host "Mode: SelfCheck (policy JSON + workflow pins + fixtures; no cargo-cyclonedx)" + + Assert-File -FilePath $policyPath -Label "sbom policy JSON" + Assert-File -FilePath $validFixture -Label "valid SBOM fixture" + Assert-File -FilePath $qgateWorkflow -Label "qgate workflow" + Assert-File -FilePath $releaseWorkflow -Label "release workflow" + Assert-File -FilePath $securityWorkflow -Label "security workflow" + Assert-File -FilePath $securityDoc -Label "SECURITY.md" + Assert-File -FilePath $selfPath -Label "sbom validate script" + Assert-File -FilePath $wrapperTest -Label "sbom_validate.rs wrapper" + + $policy = Get-Content -LiteralPath $policyPath -Raw | ConvertFrom-Json + $version = [string]$policy.cargo_cyclonedx_version + if ([string]::IsNullOrWhiteSpace($version)) { + throw "sbom-policy.json missing cargo_cyclonedx_version." + } + [void](Write-Check -Label "sbom-policy cargo_cyclonedx_version present" -Ok $true) + + $installNeedle = "cargo install cargo-cyclonedx --version $version --locked" + $qgate = Get-Content -LiteralPath $qgateWorkflow -Raw + $release = Get-Content -LiteralPath $releaseWorkflow -Raw + $security = Get-Content -LiteralPath $securityWorkflow -Raw + $secDoc = Get-Content -LiteralPath $securityDoc -Raw + + Assert-Contains -Doc $qgate -Needle $installNeedle -Label "qgate pinned cargo-cyclonedx install" -Context ".github/workflows/qgate.yml" + Assert-Contains -Doc $release -Needle $installNeedle -Label "release pinned cargo-cyclonedx install" -Context ".github/workflows/release.yml" + Assert-Contains -Doc $qgate -Needle "sbom-validate-check.ps1" -Label "qgate SBOM validate step" -Context ".github/workflows/qgate.yml" + Assert-Contains -Doc $security -Needle "sbom-validate-check.ps1 -SelfCheck" -Label "security.yml SBOM SelfCheck job" -Context ".github/workflows/security.yml" + Assert-Contains -Doc $secDoc -Needle "sbom-policy.json" -Label "SECURITY.md sbom policy cross-link" -Context "SECURITY.md" + + Write-Host "Fixture validation:" + [void](Test-CycloneDxSbom -FilePath $validFixture) + [void](Write-Check -Label "valid minimal fixture passes schema anchors" -Ok $true) + + $invalid = Join-Path ([System.IO.Path]::GetTempPath()) "sbom-invalid-selfcheck.cdx.json" + '{"version":1}' | Set-Content -LiteralPath $invalid -Encoding utf8 + $failed = $false + try { + [void](Test-CycloneDxSbom -FilePath $invalid) + } + catch { + $failed = $true + } + finally { + Remove-Item -LiteralPath $invalid -ErrorAction SilentlyContinue + } + if (-not $failed) { + throw "Expected invalid SBOM fixture to fail validation." + } + [void](Write-Check -Label "invalid fixture rejected" -Ok $true) + + Write-Host "SBOM validate SelfCheck passed (C04 L32 pinned cargo-cyclonedx + schema anchors)." + exit 0 +} + +if ($SelfCheck) { + Invoke-SelfCheck +} + +if ([string]::IsNullOrWhiteSpace($Path)) { + throw "Specify -Path or -SelfCheck." +} + +$resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) +[void](Test-CycloneDxSbom -FilePath $resolved) +Write-Host "CycloneDX SBOM validation passed for $resolved." +exit 0 diff --git a/tests/sbom_validate.rs b/tests/sbom_validate.rs new file mode 100644 index 00000000..38578bea --- /dev/null +++ b/tests/sbom_validate.rs @@ -0,0 +1,32 @@ +//! Hermetic `SelfCheck` for SBOM policy + `CycloneDX` anchors (C04 L32). +//! +//! Local: `pwsh ./scripts/sbom-validate-check.ps1 -SelfCheck` + +use std::path::PathBuf; +use std::process::Command; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +#[test] +fn sbom_validate_self_check_validates_policy_and_fixtures() { + let script = repo_root().join("scripts/sbom-validate-check.ps1"); + assert!(script.is_file(), "expected SBOM validate script at {}", script.display()); + + let output = Command::new("pwsh") + .args(["-NoProfile", "-File", script.to_str().expect("utf-8 script path"), "-SelfCheck"]) + .output() + .unwrap_or_else(|error| panic!("failed to spawn pwsh for SelfCheck: {error}")); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "sbom-validate-check.ps1 -SelfCheck failed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stdout.contains("SBOM validate SelfCheck passed"), + "expected SelfCheck success line, got:\n{stdout}" + ); +}