Umbraco.Internal.Sbom 1.2.19

Prefix Reserved
dotnet tool install --global Umbraco.Internal.Sbom --version 1.2.19
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local Umbraco.Internal.Sbom --version 1.2.19
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=Umbraco.Internal.Sbom&version=1.2.19
                    
nuke :add-package Umbraco.Internal.Sbom --version 1.2.19
                    

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 map-stream@0.1.0 ships a LICENCE that starts with Copyright (c) ... and the canonical MIT permission grant; the tool resolves that to MIT. The body-text pass also recognises the Walmart-Labs / Hapi-family BSD-3-Clause variant (clause 3 narrowed to only restrict contributor names) shipped by @hapi/* npm packages without a distinctive header — resolves to the SPDX identifier LicenseRef-BSD-3-Clause-Walmart, classified as permissive.
  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.",
        "versions": "[3.0.0, 4.0.0)"
      },
      {
        "pattern": "SixLabors.*",
        "reason": "Same Split License family — reviewed 3.x and 4.x.",
        "versions": "[3.0.0, 5.0.0)"
      }
    ]
  }
}

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

The versions field is optional and uses NuGet version-range syntax (see Override-rule format below for examples). When set, the allow entry only applies to versions inside the range; versions outside fall through to the category-level policy check. Use this to bound a per-package allowance to the audited major(s) so a future upstream license change at a major bump is surfaced rather than silently honoured.

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, allowed-via-dev-dependency, allowed-via-curated-exception, or violated). Components allowed via --allow-package / --policy-allow-file additionally carry umbraco-sbom:policy-allow-source (cli-flag or file:<basename>) and umbraco-sbom:override-version-constraint (all or the matched range string).

Build-time-only dependencies are auto-detected and emitted in the SBOM with CycloneDX scope="excluded" plus umbraco-sbom:scope=dev-only, short-circuiting the policy step to policy-decision=allowed-via-dev-dependency regardless of licence category — their terms don't bind the shipped artifact. Detection per ecosystem:

Ecosystem Signal
NuGet PrivateAssets="all" in csproj → suppressParent: "all" in project.assets.json
npm "dev": true on the package entry in package-lock.json v2/v3 (i.e. reached only through devDependencies)

npm note: only dev: true triggers — peer: true, optional: true, and inBundle: true packages are still treated as Runtime (they are runtime-relevant in different ways but not excluded from distribution).

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>
versions (optional) Scope the rule to specific versions of the matched package. NuGet-style version-range syntax (e.g., "[1.0.0, 3.0.0)", "[1.4.2]", "[3.0.0, )"). Omit for legacy "applies to all versions" semantics. Recorded as umbraco-sbom:override-version-constraint provenance — "all" for unbounded rules, raw range string for bounded.
preApproved (optional, license only) Centrally pre-approve the rule so it passes the license-policy gate in every consuming project — no CLI flag or per-project allow entry needed. Requires a non-empty comment. <license> and category classification unaffected — only umbraco-sbom:policy-decision differs (allowed-via-curated-exception).

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

Bounded version overrides (007)

A bounded versions field guards against silent license drift at major-version bumps. Example:

{
  "pattern": "starkbank-ecdsa",
  "license": "MIT",
  "versions": "[1.0.0, 3.0.0)",
  "comment": "MIT in majors 1.x and 2.x. Bounded so a future major-version license change is caught by the licence-policy gate rather than silently honoured."
}

For starkbank-ecdsa@1.4.2 → override applies (MIT recorded). For starkbank-ecdsa@3.0.0 → override does NOT apply; the natural feed metadata is used and the licence-policy gate (006) catches any unexpected license change.

The same versions field works in the consumer --policy-allow-file schema (006) for per-package allowances:

{
  "pattern": "SixLabors.ImageSharp",
  "reason": "Reviewed 3.x Six Labors Split License terms 2026-04. v4.x must be re-audited before bump.",
  "versions": "[3.0.0, 4.0.0)"
}

Range syntax follows NuGet's package versioning rules: inclusive [, exclusive (, open-ended , ) / ( ,, exact-version [1.4.2], and so on. Stable-only ranges (no pre-release endpoint) do NOT match pre-release versions per NuGet's standard pre-release rule.

Two rules sharing both (ecosystem, pattern) and overlapping versions are rejected at startup with exit-30 — author non-overlapping ranges or merge the rules.

Curated exceptions (009)

preApproved: true centrally pre-approves a specific third-party package's license so every consuming project's policy gate passes for it — no --allow-commercial/--allow-copyleft, no --allow-package, no --policy-allow-file entry required anywhere. This is an embedded-overrides.json-only mechanism: it is not available on the consumer --policy-allow-file schema, so granting a curated exception always requires a reviewed PR to this tool.

{
  "pattern": "Microsoft.Data.SqlClient.SNI.runtime",
  "license": "LicenseRef-Microsoft-SqlClient-SNI-EULA",
  "versions": "[6.0.0, 7.0.0)",
  "preApproved": true,
  "comment": "Ships under the Microsoft Software License Terms EULA, which permits object-code redistribution as part of a consuming application (§3.a, Distributable Code). Centrally reviewed and pre-approved 2026-09."
}

Only the policy-gate decision changes — the recorded <license> and category classification (commercial in the example above) are computed exactly as they would be without preApproved, so downstream tooling (e.g. Dependency-Track) still sees the real license. A rule with preApproved: true MUST also set license (never licenseName) and a non-empty comment; both are validated at startup (exit-30 on violation). See specs/009-curated-policy-exception/quickstart.md for the full maintainer walkthrough, including when NOT to reach for this mechanism.

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.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
1.2.19 361 9/7/2026
1.2.18 102 9/4/2026
1.2.17 95 9/4/2026
1.2.16 959 5/15/2026
1.1.15 116 5/15/2026
1.0.14 121 5/15/2026
1.0.13 113 5/15/2026
1.0.12 154 5/11/2026
1.0.11 129 5/11/2026
1.0.10 146 5/8/2026
0.1.9 129 5/7/2026
0.1.6 110 5/7/2026
0.1.5 110 5/7/2026
0.1.4 117 5/7/2026