Add SBOM generation in CycloneDX and SPDX format - #226
Conversation
PackageGuard already resolves the full dependency graph and enriches it with license and OSV vulnerability data. This adds --sbom cyclonedx and --sbom spdx (paired with --sbom-output <path>) so that resolved graph can be emitted as a standards-compliant SBOM, instead of teams having to run a second scanner over the same dependency tree. Both formats are rendered from one shared, internal model (PackageGuard.Core.Sbom.SbomModelBuilder) so purl construction, dependency-graph shaping, license-evidence classification, and vulnerability shaping happen exactly once: - Package URLs (purl) for every component, including scoped npm packages (pkg:npm/%40scope/name@version). - One aggregate SBOM per run across every analyzed project, with a synthetic root component representing the solution. - Direct vs. transitive dependencies, using the existing DependencyDepth/DependencyKeys graph data. Only NuGet builds a real parent-child graph today; npm/yarn/pnpm packages are recorded as direct dependencies of the root rather than a fabricated tree, and both formats call this out explicitly (CycloneDX metadata property / SPDX document comment) rather than silently claiming a graph that isn't there. - License evidence: a new PackageInfo.LicenseEvidence field records whether a license was declared by the package's own metadata or concluded from external evidence (GitHub repository scan, license text heuristics), rendered as CycloneDX license acknowledgement / SPDX licenseDeclared vs. licenseConcluded. - Vulnerabilities: OsvRiskEnricher now retains individual OSV vulnerability records (previously only aggregated), surfaced as a CycloneDX vulnerabilities section / SPDX package annotations, but only when --report-risk is also passed in the same run so --sbom alone never triggers an OSV fetch. Both writers are hand-rolled JSON (System.Text.Json + JsonPropertyName DTOs), matching the existing RiskSarifReportWriter convention, so no new SBOM library dependency is introduced. Public API additions (LicenseEvidence, OsvVulnerabilityRecord, PackageInfo.LicenseEvidence/.Vulnerabilities) are approved via AcceptApiChanges. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts: # Src/PackageGuard.Core/InternalsVisibleTo.cs # Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs # Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs # Src/PackageGuard/AnalyzeCommand.cs
Adds a third self-scan run to the RunPackageGuard target that invokes --sbom=cyclonedx --sbom-output, then asserts the file was actually created (Assert.FileExists), so a regression in SBOM generation fails the build the same way the existing risk-report run would. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Coverage Report for CI Build 32060015448Warning No base build found for commit Coverage: 78.454%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats💛 - Coveralls |
File.WriteAllText throws DirectoryNotFoundException when --sbom-output points at a path whose parent directory doesn't exist yet (discovered while manually validating the new RunPackageGuard SBOM step, whose own directory happens to already exist by the time it runs). Create the directory first, matching how the risk report writer already handles this. Also exposes AnalyzeCommand.WriteSbom as internal so it can be exercised directly in a regression test without going through the full Spectre CLI pipeline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
InspectCode found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
- Remove SbomComponent.Ecosystem: computed but never read after
construction (ecosystem is already encoded in each component's purl
and separately tracked per-ecosystem on SbomModel.EcosystemGraphIsAccurate).
- SpdxSbomWriter: use a format specifier in the interpolated severity
string instead of ToString("0.0"), and target-typed new() where the
type is already evident from the surrounding List<SpdxRelationship>.
- SbomEndToEndSpecs: drop two redundant using directives.
The remaining UnusedMember.Local/UnusedAutoPropertyAccessor findings on
CycloneDxSbomWriter/SpdxSbomWriter's private JSON DTOs are the same
false-positive pattern InspectCode already reports (and the project
already accepts) for RiskSarifReportWriter's identically-shaped DTOs:
static analysis can't see that System.Text.Json reads/writes these
properties via reflection during (de)serialization.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Went through the InspectCode findings introduced by this PR (filtered to files this PR touches, cross-checked against Fixed (
Not changed — the remaining The remaining handful of flagged lines in 🤖 Addressed by Claude Code |
The previous run's build/test/analyze job failed only on a transient Coveralls 503 rate-limit, and a separate test-results reporting job got stuck in-progress and wouldn't respond to cancellation. Empty commit to get a clean workflow run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
011e76b to
e5eed56
Compare
Both issues surfaced from running the generated files through the official CycloneDX 1.6 and SPDX 2.3 JSON schemas: - CycloneDX: the tool component used a 'vendor' string property, which CycloneDX 1.6 removed from the component schema in favor of 'manufacturer' (an organizationalEntity object with a 'name' field). - SPDX: creationInfo.created and every annotation's annotationDate used .NET's default DateTimeOffset round-trip format (2026-08-17T19:12:46.6595686+00:00), which isn't valid per SPDX 2.3 - it requires strict 'YYYY-MM-DDThh:mm:ssZ': no fractional seconds and a literal 'Z' instead of a numeric offset. Verified against a real generated SBOM: both fields now match the required formats, and added regression tests for each so a future regression fails the build instead of only showing up in downstream SBOM validators (dependency-track, sbom-utility, etc.). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Closes #210.
PackageGuard already resolves the full dependency graph (NuGet + npm/yarn/pnpm) and enriches it with license and OSV vulnerability data via
--report-risk. This PR adds the ability to emit that same resolved graph as a standards-compliant SBOM, so teams don't need to run a second scanner over the same dependency tree to satisfy EU CRA / US EO 14028-style requirements.What's included
Both formats are rendered from one shared, internal model (
PackageGuard.Core.Sbom.SbomModelBuilder) so purl construction, dependency-graph shaping, license-evidence classification, and vulnerability shaping happen exactly once instead of being duplicated per writer:pkg:npm/%40scope/name@version).scope/dependsOnand SPDXDEPENDS_ONrelationships, using the existingDependencyDepth/DependencyKeysgraph data.PackageInfo.LicenseEvidencefield records whether a license was declared by the package's own metadata or concluded from external evidence (GitHub repository scan, license-text heuristics). Rendered as CycloneDX licenseacknowledgement(declared/concluded) and SPDX's separatelicenseDeclared/licenseConcludedfields.OsvRiskEnrichernow retains individual OSV vulnerability records (previously only aggregated counts), surfaced as a CycloneDXvulnerabilitiessection and SPDX per-package annotations, but only when--report-riskis also passed in the same run —--sbomalone never triggers an OSV fetch.Known limitation, called out explicitly in the SBOM output and the README: only NuGet currently builds a real parent-child dependency graph. npm/yarn/pnpm packages are recorded as direct dependencies of the root rather than a fabricated nested tree — CycloneDX gets a
metadata.propertiesnote and SPDX a documentcommentexplaining this, so downstream consumers (e.g. security scanners) don't mistake a flat list for a complete graph. Real npm/yarn/pnpm graph parsing is a good candidate for a follow-up issue (three independent, non-trivial parser efforts).Design notes
CycloneDxSbomWriter,SpdxSbomWriter) are hand-rolled JSON (System.Text.Json+[JsonPropertyName]DTOs), matching the existingRiskSarifReportWriterconvention — no new SBOM library dependency introduced.SbomModel/SbomModelBuilder/PackageUrlBuilder/SbomComponenttypes live inPackageGuard.Corebut areinternal, so they're reusable if the engine is ever exposed as a NuGet package (an existing roadmap item) without any public API-approval cost today.LicenseEvidence,OsvVulnerabilityRecord,PackageInfo.LicenseEvidence/.Vulnerabilities) are approved viaAcceptApiChanges.Testing
Src/PackageGuard.Specs/Sbom/(purl construction, model building, both writers' JSON structure, CLI settings validation), plus an end-to-end spec running the full pipeline against theSimpleAppfixture.ParallelPackageRiskEnricherSpecsto assertPackageInfo.Vulnerabilitiesis populated alongside the existing aggregate OSV fields.mainwithout any of these changes).--sbom cyclonedxand--sbom spdxagainst theSimpleAppfixture, inspecting the generated purls, root component, direct/transitive scoping, dependency graph, and license evidence fields.🤖 Generated with Claude Code