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
6 changes: 5 additions & 1 deletion .github/workflows/qgate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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'
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
20 changes: 20 additions & 0 deletions docs/ops/fixtures/sbom-valid-minimal.cdx.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
17 changes: 17 additions & 0 deletions docs/ops/sbom-policy.json
Original file line number Diff line number Diff line change
@@ -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."
}
181 changes: 181 additions & 0 deletions scripts/sbom-validate-check.ps1
Original file line number Diff line number Diff line change
@@ -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 <file.cdx.json> or -SelfCheck."
}

$resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
[void](Test-CycloneDxSbom -FilePath $resolved)
Write-Host "CycloneDX SBOM validation passed for $resolved."
exit 0
32 changes: 32 additions & 0 deletions tests/sbom_validate.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}
Loading