Skip to main content
Any Framework

Generate a CycloneDX 1.6 XML SBOM for a NuGet, npm, or Terraform project. Drop-in replacement for the dotnet CycloneDX and @cyclonedx/cyclonedx-npm tools used in CI pipelines, with layered license resolution (registry metadata β†’ maintained override list) and an embedded SPDX license-override list curated for Umbraco's dependency surface (System.*, Microsoft.Extensions.*, hashicorp/*, …).

Published
May 8, 2026
5mo ago
Total Downloads
3.1K
3,072

Install

dotnet add package Umbraco.Internal.Sbom --version 1.0.10

No framework information available.

README

Umbraco.Internal.Sbom

build

A .NET 10 global tool that generates a CycloneDX 1.6 XML SBOM for a NuGet, npm, or Terraform project. Drop-in replacement for the dotnet CycloneDX and @cyclonedx/cyclonedx-npm tools used in Umbraco's CI pipelines, with layered license resolution and a curated SPDX license-override list bundled with the tool.

Install

dotnet tool install -g Umbraco.Internal.Sbom

In CI, install fresh in every build to ensure the latest override rules:

- script: dotnet tool install -g Umbraco.Internal.Sbom
- script: umbraco-sbom $(project_path) --output-file $(Build.SourcesDirectory)/sbom.xml

Usage

umbraco-sbom <project-path> [--output-file <path>] [policy options]

Policy options (006-license-policy-flags):

Flag Effect
--allow-copyleft Permit copyleft-category licenses (GPL, LGPL, AGPL, EPL, …).
--allow-commercial Permit commercial-category licenses (residual: non-permissive, non-copyleft, non-first-party).
--fail-on-unknown Strict gating on unknown licenses. Default: lenient β€” unknowns pass and are recorded as policy-decision=allowed-via-unknown.
--allow-package <ecosys>:<pattern> Per-package exception. Repeatable. Wildcards as in overrides.json. Example: --allow-package nuget:SixLabors.*.
--policy-allow-file <path> Load per-package exceptions from a JSON file (schemaVersion 1).
--help-licenses Print the embedded SPDX β†’ category classification table and exit.

Default behaviour: copyleft and commercial categories require explicit opt-in (--allow-copyleft / --allow-commercial) or a per-package allow rule; otherwise the run fails (exit 50). The unknown category is lenient by default β€” unknowns pass and are recorded as allowed-via-unknown so transient feed-lookup misses don't fail CI; pass --fail-on-unknown to opt into strict gating. First-party Umbraco free-text licenses always pass via the embedded override list.

The tool autodetects the ecosystem from the input path:

Marker Ecosystem Mode
Path is *.csproj (or directory containing it without a solution) NuGet Single project
Path is *.sln / *.slnx (or directory containing exactly one) NuGet Solution β€” combined SBOM across every project listed
Path is, or directory contains, package.json / package-lock.json npm Single project
Path is, or directory contains, .terraform.lock.hcl or *.tf Terraform Single project

Solution mode walks every .csproj referenced by the solution, deduplicates components on (name, version), and emits one CycloneDX 1.6 XML SBOM. First-party project-to-project references are excluded.

Within a directory, a solution file takes precedence over loose .csproj siblings. Multiple solution files in one directory exit 41. Mixed-ecosystem paths (e.g., .csproj + package.json in the same directory) also exit 41; call once per project in that case.

License resolution

For each component, license is resolved in fixed order:

  1. Metadata (cache) β€” <license> from the package's registry metadata (NuGet .nuspec in ~/.nuget/packages/<pkg>/<version>/, or npm license field in package-lock.json).
  2. Metadata (NuGet feed) β€” for NuGet only, when the cache lookup misses, query the configured feeds via NuGet.Protocol. Honors NuGet.config (system, user, repo scope) and inline <packageSourceCredentials>. Per FR-021, credential failures and unreachable sources log a warning and fall through to the next source rather than aborting the run.
  3. In-package LICENSE detection β€” inspect the package archive's LICENSE / LICENSE.txt / LICENCE / COPYING file (NuGet + npm) and match against canonical SPDX license-name headers and body-text patterns (MIT, Apache-2.0, BSD-2/3-Clause, ISC, MPL-2.0, GPL/LGPL/AGPL, Unlicense). The body-text pass closes the gap for packages that ship a license file with no header β€” e.g., npm [email protected] ships a LICENCE that starts with Copyright (c) ... and the canonical MIT permission grant; the tool resolves that to MIT.
  4. Override β€” embedded curated rules in overrides.json. Currently includes System.*, Microsoft.Extensions.*, Microsoft.NETCore.*, runtime.*, NETStandard.Library* β†’ MIT (NuGet); @types/* β†’ MIT (npm); registry.terraform.io/hashicorp/* and registry.terraform.io/integrations/* β†’ MPL-2.0 (Terraform); and first-party Umbraco.* (NuGet) + @umbraco-cms/* / @umbraco/* (npm) β†’ free-text Umbraco license (see "Override-rule format" below).
  5. Unknown β€” recorded with umbraco-sbom:license-source=unknown and exit code 10 (success-with-warnings). The CLI also prints the affected components to stderr.

Each component carries provenance properties (umbraco-sbom:license-source, matched-override-rule, resolution-status, source-manifest, policy-decision, optionally policy-allow-source) so a reviewer can audit how every license was determined without re-running the tool.

License policy

Resolved licenses are classified into one of five categories: permissive, copyleft, commercial, unknown, or first-party-override (free-text Umbraco licenses from the embedded override list, always allowed). The default posture is strict β€” copyleft, commercial fail the run unless explicitly opted-in via flags; unknowns pass by default but flip to fail with --fail-on-unknown.

Run umbraco-sbom --help-licenses to print the embedded SPDX β†’ category table.

Per-package exceptions (e.g., a custom license that only one specific package carries) are configured via:

# Repeatable CLI flag β€” handy for ad-hoc invocations:
umbraco-sbom <project> --allow-package nuget:SixLabors.ImageSharp

# Or via a JSON file checked in alongside the consuming repo's CI:
umbraco-sbom <project> --policy-allow-file .sbom-policy.json

.sbom-policy.json shape (schemaVersion 1):

{
  "schemaVersion": 1,
  "rules": {
    "nuget": [
      {
        "pattern": "SixLabors.ImageSharp",
        "reason": "Six Labors Split License β€” Umbraco-CMS uses under the free tier."
      },
      {
        "pattern": "SixLabors.*",
        "reason": "Same Split License family."
      }
    ]
  }
}

The reason field is required when pattern contains a wildcard (*) β€” broad exemptions need broader justification. Exact-match patterns may omit it.

Every component in the produced SBOM carries umbraco-sbom:policy-decision (permissive-default, allowed-via-copyleft, allowed-via-commercial, allowed-via-unknown, allowed-via-first-party-override, allowed-via-package-override, or violated). Components allowed via --allow-package / --policy-allow-file additionally carry umbraco-sbom:policy-allow-source (cli-flag or file:<basename>).

Output

CycloneDX 1.6 XML at the path passed via --output-file (default ./sbom.xml). Output is byte-identical across consecutive runs with unchanged inputs (SOURCE_DATE_EPOCH honored for reproducible-build pipelines).

Exit codes

Code Meaning
0 Success β€” every component fully resolved AND policy-allowed.
10 Success-with-warnings β€” legacy resolution-status=unresolved path (genuinely-unresolvable components).
20 Schema validation failure β€” no output written.
30 Embedded override list, classification list, or --policy-allow-file failed startup validation.
40 No recognized manifests at the input path.
41 Mixed-ecosystem path β€” markers for multiple ecosystems present.
50 License-policy violation β€” at least one component requires a flag that was not passed.
1 Unhandled error.

Requirements

  • .NET 10.0 SDK on the path running the tool.
  • For NuGet projects: dotnet restore must have populated obj/project.assets.json. Solutions enumerate every .csproj they list.
  • For npm projects: npm install or npm ci must have produced package-lock.json (lockfileVersion 2 or 3; v1 not supported).
  • For Terraform projects: terraform init must have populated .terraform.lock.hcl, or *.tf files must contain registry-sourced module blocks with explicit version = "...".

Architecture

Clean Architecture, four projects:

Umbraco.Internal.Sbom.Domain          β€” value types, entities, no external deps
Umbraco.Internal.Sbom.Application     β€” orchestrators + abstractions
Umbraco.Internal.Sbom.Infrastructure  β€” NuGet/npm/Terraform scanners,
                                        CycloneDX serialization, embedded
                                        override rules
Umbraco.Internal.Sbom.Cli             β€” .NET global tool host (umbraco-sbom)

The license-resolution chain is composed via DI in ServiceCollectionExtensions and runs in registration order: MetadataLicenseStep β†’ NuGetFeedMetadataStep β†’ InPackageLicenseStep β†’ OverrideLicenseStep β†’ UnknownLicenseStep.

Testing

Tests use TUnit (MIT) with native await Assert.That(...) assertions β€” no external fluent-assertion library. Run with dotnet test; the underlying runner is Microsoft Testing Platform (MTP), opted into via global.json. CI invocation: dotnet test ... -- --report-trx --report-trx-filename test-results.trx.

The license-policy rule for the repository is no commercial-licensed dev-time dependencies. This rule motivated the move from FluentAssertions 6.12.2 (last MIT release; v8 is commercial-Xceed) to TUnit's native assertions in commit 002-tunit-migration.

Per-test-method parallelism is the default for unit tests. Tests that drive external state (Cli.E2ETests, NuGetFeedLicenseLookupTests, GitRevisionProviderTests) carry [NotInParallel]. See specs/002-tunit-migration/contracts/tunit-conventions.md for the full convention.

Repository layout

src/                            source projects
tests/                          unit, integration, and E2E tests (235 total)
specs/001-sbom-generator/       feature spec, plan, tasks, contracts, quickstart
.specify/                       Spec Kit workflows and templates
CHANGELOG.md                    release notes

Releasing

Continuous delivery: every successful build workflow on main automatically triggers the release workflow, which packs, runs the self-SBOM check, publishes to NuGet.org, and uploads to Dependency-Track. There is no manual "go" gate β€” merging to main is the release.

Versions follow Major.Minor.BuildNumber. The major and minor components live in src/Umbraco.Internal.Sbom.Cli/Umbraco.Internal.Sbom.Cli.csproj as <MajorVersion> + <MinorVersion> MSBuild properties β€” the only thing you maintain by hand. The patch (BuildNumber) is set by CI from ${{ github.run_number }} so every published package has a strictly larger version than the previous one.

To influence a release:

You want… Do this
Roll out a new build (most common) Merge a PR to main. The build workflow runs; on success, release auto-publishes ${MajorVersion}.${MinorVersion}.${run_number}.
Bump major or minor before next release Edit <MajorVersion> / <MinorVersion> in the Cli csproj, merge to main. The next merge auto-publishes under the new prefix.
Cut an explicit SemVer release (e.g. 1.0.0) git tag v1.0.0 && git push --tags. The tag-push trigger uses the tag verbatim and creates a GitHub release with notes.

Concurrent merges to main queue (concurrency: release-… with cancel-in-progress: false) so two PRs landing back-to-back don't race for the same run_number. The release for each one runs in sequence.

Local dotnet pack produces ${MajorVersion}.${MinorVersion}.0 packages (BuildNumber defaults to 0 when BUILD_NUMBER is unset) β€” never publish a package built locally; CI is the source of truth for published versions.

Self-SBOM artifact

Every release attaches a Umbraco.Internal.Sbom.<version>.sbom.xml to the GitHub release alongside the .nupkg and .snupkg. The SBOM is produced by the just-built tool from the same workflow run (no chicken-and-egg β€” the release pipeline installs the local nupkg via --add-source ./artifacts and runs it against this repo's own solution). The result documents the tool's own dependency graph at the released version: every component's resolved license, source manifest, and provenance source.

Behavior is lenient: unknown licenses produce a ::warning:: on the workflow run and an entry in the run summary's "SBOM self-check" block, but the release proceeds.

Auto-on-main runs (no tag, no GitHub release created) upload the SBOM to the workflow artifact bundle named nupkg, downloadable from the run page. Tag-push runs additionally attach the SBOM file to the GitHub release.

Dependency-Track upload

After every successful NuGet push, the release workflow also POSTs the self-SBOM to Umbraco's central Dependency-Track instance under project name Umbraco.Internal.Sbom and the same SemVer version used for dotnet pack. This puts the tool's component graph into the same audit surface used for every other Umbraco backend repo, so a compliance reviewer can find any released version's component list, license posture, and CVE matches in DT directly.

The upload runs between the NuGet push and the GitHub-release creation. If DT is unreachable or returns an error after 3 retries, the workflow turns red and no GitHub release is created β€” the acknowledged side-effect is "the package is on NuGet.org but no GH release exists yet"; merging a follow-up commit (or pushing a tag again) re-runs the release pipeline and recovers from that state.

Required GitHub configuration (org-level or repo-level):

Name Kind Value
DTRACK_API_KEY secret DT API token with BOM_UPLOAD and PROJECT_CREATION_UPLOAD permissions.
DTRACK_API_URI variable Full URL including /api/v1/bom, e.g. https://<dt-host>/api/v1/bom.

When either is unset (forks, personal builds), the upload step soft-skips and the workflow stays green β€” same ergonomics as the existing NuGet-push step.

See specs/004-dependency-track-upload/ for the full spec, plan, and contract; the maintainer walkthrough for verifying an upload lives in specs/004-dependency-track-upload/quickstart.md.

Override-rule format

overrides.json (schema v2) groups rules by ecosystem. Each rule sets exactly one of license or licenseName:

{
  "schemaVersion": 2,
  "rules": {
    "nuget": [
      { "pattern": "System.*",  "license": "MIT",
        "comment": "Microsoft .NET Foundation, MIT-licensed." },
      { "pattern": "Umbraco.*", "licenseName": "Umbraco",
        "comment": "First-party Umbraco packages β€” free-text name covers both MIT (Cms) and commercial (Forms/Deploy/Heartcore) terms." }
    ],
    "npm":    [ ... ],
    "terraform": [ ... ]
  }
}
Field When to use CycloneDX rendering
license License has an SPDX identifier (e.g., MIT, Apache-2.0, or a compound expression like MIT OR Apache-2.0). <license><id>MIT</id></license> (single-token) or <license><expression>...</expression></license> (compound)
licenseName License has no SPDX identifier β€” typically first-party or vendor-specific licenses where reviewers already know the terms. <license><name>Umbraco</name></license>

license and licenseName are mutually exclusive. The longest-non-wildcard-prefix rule wins ties; on equal prefixes, ordinal-by-pattern lex order wins.

Contributing

Override-rule maintenance happens in src/Umbraco.Internal.Sbom.Infrastructure/Overrides/overrides/overrides.json. The file is validated by the embedded loader at startup; an invalid override list exits with code 30 rather than producing a partial SBOM.

License

MIT β€” see LICENSE.

Changes from Previous Version

No structural changes between v0.1.9 and v1.0.10.

Adoption Assessment

A verified publisher signal is available. Assess fit from the package's supported frameworks, license and dependency graph rather than popularity alone.

No target framework metadata is available. Confirm compatibility in a representative project before standardising on this package.

Related Guides & Workflows

Learn how to evaluate, integrate, or audit this package in your .NET project.