Skip to content

Add SBOM generation in CycloneDX and SPDX format - #226

Draft
dennisdoomen wants to merge 7 commits into
mainfrom
feature/sbom-generation
Draft

Add SBOM generation in CycloneDX and SPDX format#226
dennisdoomen wants to merge 7 commits into
mainfrom
feature/sbom-generation

Conversation

@dennisdoomen

Copy link
Copy Markdown
Owner

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.

packageguard --sbom cyclonedx --sbom-output bom.json <path-to-project>
packageguard --sbom spdx --sbom-output bom.spdx.json <path-to-project>

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:

  • 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, rendered as CycloneDX scope/dependsOn and SPDX DEPENDS_ON relationships, using the existing DependencyDepth/DependencyKeys graph data.
  • 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 (declared/concluded) and SPDX's separate licenseDeclared/licenseConcluded fields.
  • VulnerabilitiesOsvRiskEnricher now retains individual OSV vulnerability records (previously only aggregated counts), surfaced as a CycloneDX vulnerabilities section and SPDX per-package annotations, but only when --report-risk is also passed in the same run — --sbom alone 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.properties note and SPDX a document comment explaining 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

  • Both writers (CycloneDxSbomWriter, SpdxSbomWriter) are hand-rolled JSON (System.Text.Json + [JsonPropertyName] DTOs), matching the existing RiskSarifReportWriter convention — no new SBOM library dependency introduced.
  • The shared SbomModel/SbomModelBuilder/PackageUrlBuilder/SbomComponent types live in PackageGuard.Core but are internal, 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.
  • Public API additions (LicenseEvidence, OsvVulnerabilityRecord, PackageInfo.LicenseEvidence/.Vulnerabilities) are approved via AcceptApiChanges.

Testing

  • 29 new specs under 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 the SimpleApp fixture.
  • Extended ParallelPackageRiskEnricherSpecs to assert PackageInfo.Vulnerabilities is populated alongside the existing aggregate OSV fields.
  • Full solution build + test suite passes (192/194 — the 2 failures are pre-existing GitHub-API-rate-limit flakiness, confirmed present on main without any of these changes).
  • Manually verified --sbom cyclonedx and --sbom spdx against the SimpleApp fixture, inspecting the generated purls, root component, direct/transitive scoping, dependency graph, and license evidence fields.

🤖 Generated with Claude Code

dennisdoomen and others added 3 commits August 16, 2026 14:37
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>
@coveralls

coveralls commented Aug 16, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 32060015448

Warning

No base build found for commit 6cd0a4f on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 78.454%

Details

  • Patch coverage: 33 uncovered changes across 8 files (539 of 572 lines covered, 94.23%).

Uncovered Changes

File Changed Covered %
Src/PackageGuard.Core/PackageInfoCollection.cs 11 4 36.36%
Src/PackageGuard.Core/Sbom/SbomModelBuilder.cs 68 61 89.71%
Src/PackageGuard/AnalyzeCommand.cs 22 15 68.18%
Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs 8 4 50.0%
Src/PackageGuard/CycloneDxSbomWriter.cs 198 194 97.98%
Src/PackageGuard.Core/CSharp/FetchingStrategies/UrlLicenseFetcher.cs 4 2 50.0%
Src/PackageGuard.Core/Sbom/PackageUrlBuilder.cs 22 21 95.45%
Src/PackageGuard/SpdxSbomWriter.cs 160 159 99.38%
Total (18 files) 572 539 94.23%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 6446
Covered Lines: 5268
Line Coverage: 81.73%
Relevant Branches: 2414
Covered Branches: 1683
Branch Coverage: 69.72%
Branches in Coverage %: Yes
Coverage Strength: 1900.0 hits per line

💛 - 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>
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Test Results

  3 files  ± 0    3 suites  ±0   3m 51s ⏱️ -37s
198 tests +30  198 ✅ +30  0 💤 ±0  0 ❌ ±0 
395 runs  +60  395 ✅ +60  0 💤 ±0  0 ❌ ±0 

Results for commit 375add8. ± Comparison against base commit 6cd0a4f.

♻️ This comment has been updated with latest results.

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
@dennisdoomen

Copy link
Copy Markdown
Owner Author

Went through the InspectCode findings introduced by this PR (filtered to files this PR touches, cross-checked against main's existing baseline via the code-scanning API to separate new issues from pre-existing ones that just shifted line numbers):

Fixed (8a7996b):

  • SbomComponent.Ecosystem — computed but never read after construction (ecosystem is already encoded in each component's purl, and tracked separately per-ecosystem via SbomModel.EcosystemGraphIsAccurate). Removed.
  • SpdxSbomWriter.cs — used a :0.0 format specifier in the interpolated severity string instead of .ToString("0.0"), and target-typed new() where the type was already evident from the surrounding List<SpdxRelationship>.
  • SbomEndToEndSpecs.cs — dropped two redundant using directives.

Not changed — the remaining UnusedMember.Local/UnusedAutoPropertyAccessor.* findings on CycloneDxSbomWriter/SpdxSbomWriter's private JSON DTO classes. These are the same false-positive pattern InspectCode already reports (and the project already accepts, unaddressed) for RiskSarifReportWriter's identically-shaped DTOs — 27 open findings on main right now for that file alone. Static analysis can't see that System.Text.Json reads/writes these properties via reflection during (de)serialization, so it flags every DTO property as "unused." Fixing this consistently across both old and new SBOM/SARIF writers (e.g. via [UsedImplicitly]) would be a reasonable follow-up, but felt out of scope for this PR to change unilaterally.

The remaining handful of flagged lines in PackageInfo.cs, PackageInfoCollection.cs, and NpmRegistryMetadataFetcher.cs are pre-existing findings already open on main — this PR only shifted their line numbers by adding code above them.

🤖 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>
@dennisdoomen
dennisdoomen force-pushed the feature/sbom-generation branch from 011e76b to e5eed56 Compare August 17, 2026 19:14
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Generate an SBOM in CycloneDX and SPDX format

3 participants