From 4724ad60123f71ee4244f30e68ae0c0fe01cb9d0 Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:52:37 +0200
Subject: [PATCH 01/10] Generate SBOM from complete restore graphs
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
tools/generate-sbom.ps1 | 891 ++++++++++++++++++++++++++++++----------
1 file changed, 681 insertions(+), 210 deletions(-)
diff --git a/tools/generate-sbom.ps1 b/tools/generate-sbom.ps1
index 79005092..d5753345 100644
--- a/tools/generate-sbom.ps1
+++ b/tools/generate-sbom.ps1
@@ -6,12 +6,10 @@ param(
[string]$AssetsPath,
- [string]$AppProjectPath,
+ [string]$TestAssetsPath,
[string]$PolicyPath,
- [string]$TestProjectPath,
-
[string]$WorkflowPath
)
@@ -22,15 +20,12 @@ $root = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($AssetsPath)) {
$AssetsPath = Join-Path $root 'src\SightAdapt\obj\project.assets.json'
}
-if ([string]::IsNullOrWhiteSpace($AppProjectPath)) {
- $AppProjectPath = Join-Path $root 'src\SightAdapt\SightAdapt.csproj'
+if ([string]::IsNullOrWhiteSpace($TestAssetsPath)) {
+ $TestAssetsPath = Join-Path $root 'tests\SightAdapt.Tests\obj\project.assets.json'
}
if ([string]::IsNullOrWhiteSpace($PolicyPath)) {
$PolicyPath = Join-Path $root 'release\dependency-policy.json'
}
-if ([string]::IsNullOrWhiteSpace($TestProjectPath)) {
- $TestProjectPath = Join-Path $root 'tests\SightAdapt.Tests\SightAdapt.Tests.csproj'
-}
if ([string]::IsNullOrWhiteSpace($WorkflowPath)) {
$WorkflowPath = Join-Path $root '.github\workflows\build.yml'
}
@@ -39,11 +34,9 @@ $publish = [System.IO.Path]::GetFullPath($PublishDirectory)
if (-not [System.IO.Directory]::Exists($publish)) {
throw "Publish directory does not exist: $publish"
}
-
$resolvedAssets = (Resolve-Path -LiteralPath $AssetsPath).Path
-$resolvedAppProject = (Resolve-Path -LiteralPath $AppProjectPath).Path
+$resolvedTestAssets = (Resolve-Path -LiteralPath $TestAssetsPath).Path
$resolvedPolicy = (Resolve-Path -LiteralPath $PolicyPath).Path
-$resolvedTestProject = (Resolve-Path -LiteralPath $TestProjectPath).Path
$resolvedWorkflow = (Resolve-Path -LiteralPath $WorkflowPath).Path
[xml]$props = Get-Content -LiteralPath (Join-Path $root 'Directory.Build.props')
@@ -57,24 +50,71 @@ $noticeMetadataPath = Join-Path $publish 'DOTNET-NOTICE-METADATA.json'
if (-not [System.IO.File]::Exists($noticeMetadataPath)) {
throw 'DOTNET-NOTICE-METADATA.json must be generated before SBOM generation.'
}
-
$noticeMetadata = Get-Content -LiteralPath $noticeMetadataPath -Raw | ConvertFrom-Json
+if ([int]$noticeMetadata.schemaVersion -lt 2 -or $null -eq $noticeMetadata.componentCoverage) {
+ throw 'DOTNET-NOTICE-METADATA.json does not contain component-level package coverage.'
+}
if (-not [System.IO.File]::Exists((Join-Path $publish 'SightAdapt.exe'))) {
throw 'SightAdapt.exe is missing from the publish directory.'
}
-$assets = Get-Content -LiteralPath $resolvedAssets -Raw | ConvertFrom-Json
+
$policy = Get-Content -LiteralPath $resolvedPolicy -Raw | ConvertFrom-Json
+if ([int]$policy.schemaVersion -ne 2) {
+ throw "Unsupported dependency-policy schema '$($policy.schemaVersion)'."
+}
$allowedLicenses = @($policy.allowedLicenseExpressions)
$deniedLicenses = @($policy.deniedLicenseExpressions)
$reviewLicenses = @($policy.reviewLicenseExpressions)
-$failures = [System.Collections.Generic.List[string]]::new()
-$components = [System.Collections.Generic.List[object]]::new()
-$seen = @{}
+$policySha256 = (Get-FileHash -LiteralPath $resolvedPolicy -Algorithm SHA256).Hash.ToLowerInvariant()
+
+$script:failures = [System.Collections.Generic.List[string]]::new()
+$script:packageRecords = @{}
+$script:graphEdges = [System.Collections.Generic.List[object]]::new()
+$script:edgeKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+$script:packageFolders = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+$script:coveragePackages = @{}
+
+function Convert-Base64Sha512ToHex([string]$Value) {
+ if ([string]::IsNullOrWhiteSpace($Value)) {
+ return $null
+ }
+ try {
+ $bytes = [Convert]::FromBase64String($Value.Trim())
+ }
+ catch {
+ return $null
+ }
+ if ($bytes.Length -ne 64) {
+ return $null
+ }
+ return [BitConverter]::ToString($bytes).Replace('-', '').ToLowerInvariant()
+}
+
+function Get-FileSha256([string]$Path) {
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+}
+
+function Get-NormalizedIdentity([string]$Name, [string]$Version) {
+ return "$Name/$Version"
+}
+
+function Split-PackageIdentity([string]$Identity) {
+ $parts = $Identity -split '/', 2
+ if ($parts.Count -ne 2 -or
+ [string]::IsNullOrWhiteSpace($parts[0]) -or
+ [string]::IsNullOrWhiteSpace($parts[1])) {
+ throw "Invalid package identity '$Identity'."
+ }
+ return [pscustomobject]@{
+ Name = $parts[0]
+ Version = $parts[1]
+ }
+}
function Get-PolicyEntry([string]$Name) {
$property = @(
$policy.components.PSObject.Properties |
- Where-Object { [string]$_.Name -eq $Name }
+ Where-Object { ([string]$_.Name) -ieq $Name }
) | Select-Object -First 1
if ($null -eq $property) {
return $null
@@ -82,178 +122,597 @@ function Get-PolicyEntry([string]$Name) {
return $property.Value
}
-function Add-Component(
+function Test-AnyPattern([string]$Value, [object[]]$Patterns) {
+ foreach ($pattern in $Patterns) {
+ if ($Value -like [string]$pattern) {
+ return $true
+ }
+ }
+ return $false
+}
+
+function Register-Package(
[string]$Name,
[string]$Version,
- [string]$DetectedScope) {
+ [string]$Scope,
+ [bool]$Direct,
+ [string]$Graph,
+ [string]$PackageSha512,
+ [string]$PackagePath) {
if ([string]::IsNullOrWhiteSpace($Name) -or
[string]::IsNullOrWhiteSpace($Version)) {
- $failures.Add("A dependency has an empty name or version: '$Name' '$Version'.")
- return
+ $script:failures.Add("A dependency has an empty name or version: '$Name' '$Version'.")
+ return $null
}
- $key = "$Name@$Version"
- if ($seen.ContainsKey($key)) {
- return
+ $identity = Get-NormalizedIdentity $Name $Version
+ $key = $identity.ToLowerInvariant()
+ if (-not $script:packageRecords.ContainsKey($key)) {
+ $script:packageRecords[$key] = [ordered]@{
+ name = $Name
+ version = $Version
+ identity = $identity
+ scopes = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ graphs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ direct = $false
+ shipped = $false
+ packageSha512 = $null
+ packagePath = $null
+ packageUrl = $null
+ repositoryUrl = $null
+ repositoryCommit = $null
+ coverageLicense = $null
+ }
}
-
- $entry = Get-PolicyEntry $Name
- if ($null -eq $entry) {
- $failures.Add("Dependency '$Name@$Version' has no reviewed policy entry.")
- return
+ $record = $script:packageRecords[$key]
+ if (-not [string]::IsNullOrWhiteSpace($Scope)) {
+ $record.scopes.Add($Scope) | Out-Null
}
+ if (-not [string]::IsNullOrWhiteSpace($Graph)) {
+ $record.graphs.Add($Graph) | Out-Null
+ }
+ if ($Direct) {
+ $record.direct = $true
+ }
+ if (-not [string]::IsNullOrWhiteSpace($PackageSha512)) {
+ $normalizedSha = $PackageSha512.ToLowerInvariant()
+ if (-not [string]::IsNullOrWhiteSpace([string]$record.packageSha512) -and
+ [string]$record.packageSha512 -ne $normalizedSha) {
+ $script:failures.Add("Package '$identity' has conflicting SHA-512 evidence.")
+ }
+ else {
+ $record.packageSha512 = $normalizedSha
+ }
+ }
+ if (-not [string]::IsNullOrWhiteSpace($PackagePath)) {
+ $record.packagePath = $PackagePath.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
+ }
+ return $record
+}
- $expectedVersion = [string]$entry.expectedVersion
- $versionSource = [string]$entry.expectedVersionSource
- if ($versionSource -eq 'product') {
- $expectedVersion = $productVersion
+function Add-GraphEdge([string]$FromIdentity, [string]$ToIdentity, [string]$Graph) {
+ if ([string]::IsNullOrWhiteSpace($FromIdentity) -or
+ [string]::IsNullOrWhiteSpace($ToIdentity) -or
+ $FromIdentity -ieq $ToIdentity) {
+ return
}
- elseif ($versionSource -eq 'sdk') {
- $expectedVersion = $sdkVersion
+ $key = "$Graph|$FromIdentity|$ToIdentity"
+ if ($script:edgeKeys.Add($key)) {
+ $script:graphEdges.Add([ordered]@{
+ from = $FromIdentity
+ to = $ToIdentity
+ graph = $Graph
+ })
}
- elseif ($versionSource -eq 'runtime') {
- $expectedVersion = $runtimeVersion
+}
+
+function Resolve-ExactDownloadVersion([string]$VersionRange) {
+ $match = [regex]::Match($VersionRange, '^\[([^,\]]+),\s*\1\]$')
+ if ($match.Success) {
+ return $match.Groups[1].Value
}
+ return $null
+}
- if (-not [string]::IsNullOrWhiteSpace($expectedVersion) -and
- $Version -ne $expectedVersion) {
- $failures.Add(
- "Dependency '$Name' has version '$Version'; reviewed version is '$expectedVersion'.")
+function Read-RestoreGraph([string]$Path, [string]$GraphName) {
+ $assets = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
+ foreach ($folder in @($assets.packageFolders.PSObject.Properties)) {
+ if (-not [string]::IsNullOrWhiteSpace([string]$folder.Name)) {
+ $script:packageFolders.Add([string]$folder.Name) | Out-Null
+ }
}
- $license = [string]$entry.license
- if ($deniedLicenses -contains $license) {
- $failures.Add("Dependency '$Name@$Version' uses denied license '$license'.")
+ $framework = @($assets.project.frameworks.PSObject.Properties) | Select-Object -First 1
+ if ($null -eq $framework) {
+ $script:failures.Add("Restore graph '$GraphName' has no project framework metadata.")
+ return
}
- elseif ($allowedLicenses -notcontains $license) {
- $failures.Add(
- "Dependency '$Name@$Version' uses unreviewed license '$license'.")
+ $directNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ if ($null -ne $framework.Value.dependencies) {
+ foreach ($dependency in @($framework.Value.dependencies.PSObject.Properties)) {
+ $directNames.Add([string]$dependency.Name) | Out-Null
+ }
}
- $scope = [string]$entry.scope
- if ([string]::IsNullOrWhiteSpace($scope)) {
- $scope = $DetectedScope
+ foreach ($download in @($framework.Value.downloadDependencies)) {
+ $name = [string]$download.name
+ $version = Resolve-ExactDownloadVersion ([string]$download.version)
+ if ([string]::IsNullOrWhiteSpace($version)) {
+ $script:failures.Add("Framework download dependency '$name' in '$GraphName' is not exact: '$($download.version)'.")
+ continue
+ }
+ Register-Package $name $version "$GraphName-restore" $false $GraphName $null $null | Out-Null
}
- $component = [pscustomobject]@{
- name = $Name
- version = $Version
- scope = $scope
- shipped = [bool]$entry.shipped
- supplier = [string]$entry.supplier
- license = $license
- source = [string]$entry.source
- purpose = [string]$entry.purpose
- status = if (($allowedLicenses -contains $license) -and
- ($Version -eq $expectedVersion -or
- [string]::IsNullOrWhiteSpace($expectedVersion))) {
- 'approved'
+ $graphIdentityByName = @{}
+ foreach ($libraryProperty in @($assets.libraries.PSObject.Properties)) {
+ $library = $libraryProperty.Value
+ if ([string]$library.type -ne 'package') {
+ continue
+ }
+ $parts = Split-PackageIdentity ([string]$libraryProperty.Name)
+ $scope = if ($directNames.Contains($parts.Name)) {
+ "$GraphName-direct"
}
else {
- 'failed'
+ "$GraphName-transitive"
}
+ $sha512 = Convert-Base64Sha512ToHex ([string]$library.sha512)
+ Register-Package `
+ $parts.Name `
+ $parts.Version `
+ $scope `
+ ($directNames.Contains($parts.Name)) `
+ $GraphName `
+ $sha512 `
+ ([string]$library.path) | Out-Null
+ $graphIdentityByName[$parts.Name.ToLowerInvariant()] = Get-NormalizedIdentity $parts.Name $parts.Version
}
- $components.Add($component)
- $seen[$key] = $true
-}
-
-Add-Component 'SightAdapt' $productVersion 'shipped'
-Add-Component 'Microsoft .NET SDK' $sdkVersion 'build'
-foreach ($runtimePackage in @($noticeMetadata.runtimePackages)) {
- $parts = ([string]$runtimePackage) -split '/', 2
- if ($parts.Count -ne 2) {
- $failures.Add("Invalid runtime package identity '$runtimePackage'.")
- continue
- }
- Add-Component $parts[0] $parts[1] 'runtime'
-}
-
-$assetsFramework = @(
- $assets.project.frameworks.PSObject.Properties
-) | Select-Object -First 1
-if ($null -eq $assetsFramework) {
- $failures.Add('The application restore graph has no framework metadata.')
-}
-else {
- foreach ($dependency in @($assetsFramework.Value.downloadDependencies)) {
- $dependencyName = [string]$dependency.name
- $versionRange = [string]$dependency.version
- $versionMatch = [regex]::Match(
- $versionRange,
- '^\[([^,\]]+),\s*\1\]$')
- if (-not $versionMatch.Success) {
- $failures.Add(
- "Restore dependency '$dependencyName' does not use an exact version range: '$versionRange'.")
- continue
+ foreach ($targetProperty in @($assets.targets.PSObject.Properties)) {
+ $targetIdentityByName = @{}
+ foreach ($entry in @($targetProperty.Value.PSObject.Properties)) {
+ $entryValue = $entry.Value
+ if ([string]$entryValue.type -ne 'package') {
+ continue
+ }
+ $parts = Split-PackageIdentity ([string]$entry.Name)
+ $targetIdentityByName[$parts.Name.ToLowerInvariant()] = Get-NormalizedIdentity $parts.Name $parts.Version
+ }
+ foreach ($entry in @($targetProperty.Value.PSObject.Properties)) {
+ $entryValue = $entry.Value
+ if ([string]$entryValue.type -ne 'package') {
+ continue
+ }
+ $parentParts = Split-PackageIdentity ([string]$entry.Name)
+ $parentIdentity = Get-NormalizedIdentity $parentParts.Name $parentParts.Version
+ if ($null -eq $entryValue.dependencies) {
+ continue
+ }
+ foreach ($dependency in @($entryValue.dependencies.PSObject.Properties)) {
+ $dependencyName = [string]$dependency.Name
+ $lookup = $dependencyName.ToLowerInvariant()
+ if ($targetIdentityByName.ContainsKey($lookup)) {
+ Add-GraphEdge $parentIdentity ([string]$targetIdentityByName[$lookup]) $GraphName
+ }
+ elseif ($graphIdentityByName.ContainsKey($lookup)) {
+ Add-GraphEdge $parentIdentity ([string]$graphIdentityByName[$lookup]) $GraphName
+ }
+ else {
+ $script:failures.Add("Dependency '$dependencyName' referenced by '$parentIdentity' is missing from restore graph '$GraphName'.")
+ }
+ }
}
- Add-Component $dependencyName $versionMatch.Groups[1].Value 'restore'
}
}
-[xml]$appProject = Get-Content -LiteralPath $resolvedAppProject
-foreach ($reference in @($appProject.SelectNodes('/Project/ItemGroup/PackageReference'))) {
- Add-Component ([string]$reference.Include) ([string]$reference.Version) 'runtime'
+foreach ($coveragePackage in @($noticeMetadata.componentCoverage.packages)) {
+ $parts = Split-PackageIdentity ([string]$coveragePackage.package)
+ $record = Register-Package `
+ $parts.Name `
+ $parts.Version `
+ 'shipped' `
+ $false `
+ 'publish' `
+ ([string]$coveragePackage.packageSha512) `
+ $null
+ $record.shipped = $true
+ $record.packageUrl = [string]$coveragePackage.packageUrl
+ $record.repositoryUrl = [string]$coveragePackage.repositoryUrl
+ $record.repositoryCommit = [string]$coveragePackage.repositoryCommit
+ $record.coverageLicense = [string]$coveragePackage.policyLicense
+ $script:coveragePackages[$record.identity.ToLowerInvariant()] = $coveragePackage
}
-[xml]$testProject = Get-Content -LiteralPath $resolvedTestProject
-foreach ($reference in @($testProject.SelectNodes('/Project/ItemGroup/PackageReference'))) {
- Add-Component ([string]$reference.Include) ([string]$reference.Version) 'test'
+Read-RestoreGraph $resolvedAssets 'application'
+Read-RestoreGraph $resolvedTestAssets 'test'
+
+function Register-ManualComponent(
+ [string]$Name,
+ [string]$Version,
+ [string]$Scope) {
+ Register-Package $Name $Version $Scope $true $Scope $null $null | Out-Null
}
+Register-ManualComponent 'Microsoft .NET SDK' $sdkVersion 'build'
$workflowText = Get-Content -LiteralPath $resolvedWorkflow -Raw
$actionMatches = [regex]::Matches(
$workflowText,
'uses:\s+([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)@([^\s#]+)')
foreach ($match in $actionMatches) {
- Add-Component ([string]$match.Groups[1].Value) ([string]$match.Groups[2].Value) 'build'
+ Register-ManualComponent `
+ ([string]$match.Groups[1].Value) `
+ ([string]$match.Groups[2].Value) `
+ 'build-action'
+}
+
+function Find-PackageRoot([System.Collections.IDictionary]$Record) {
+ foreach ($folder in $script:packageFolders) {
+ $candidate = if (-not [string]::IsNullOrWhiteSpace([string]$Record.packagePath)) {
+ Join-Path $folder ([string]$Record.packagePath)
+ }
+ else {
+ Join-Path (Join-Path $folder ([string]$Record.name).ToLowerInvariant()) ([string]$Record.version)
+ }
+ if ([System.IO.Directory]::Exists($candidate)) {
+ return (Resolve-Path -LiteralPath $candidate).Path
+ }
+ }
+ return $null
}
+function Get-PrimaryScope([System.Collections.IDictionary]$Record) {
+ if ([bool]$Record.shipped) {
+ return 'shipped'
+ }
+ if (@($Record.scopes | Where-Object { $_ -like 'application-*' }).Count -gt 0) {
+ return 'application-build'
+ }
+ if (@($Record.scopes | Where-Object { $_ -like 'test-*' }).Count -gt 0) {
+ return 'test'
+ }
+ if (@($Record.scopes | Where-Object { $_ -like 'build*' }).Count -gt 0) {
+ return 'build'
+ }
+ return 'other'
+}
+
+function Get-NuGetEvidence([System.Collections.IDictionary]$Record) {
+ $rootPath = Find-PackageRoot $Record
+ if ([string]::IsNullOrWhiteSpace($rootPath)) {
+ return [ordered]@{
+ evidenceType = 'missing-package-cache'
+ packageSha512 = [string]$Record.packageSha512
+ nuspecSha256 = $null
+ declaredLicense = 'UNKNOWN'
+ licenseType = 'missing'
+ licenseValue = $null
+ licenseEvidenceSha256 = $null
+ repositoryUrl = [string]$Record.repositoryUrl
+ repositoryCommit = [string]$Record.repositoryCommit
+ authors = $null
+ }
+ }
+
+ $nuspec = Get-ChildItem -LiteralPath $rootPath -File -Filter '*.nuspec' |
+ Select-Object -First 1
+ if ($null -eq $nuspec) {
+ return [ordered]@{
+ evidenceType = 'missing-nuspec'
+ packageSha512 = [string]$Record.packageSha512
+ nuspecSha256 = $null
+ declaredLicense = 'UNKNOWN'
+ licenseType = 'missing'
+ licenseValue = $null
+ licenseEvidenceSha256 = $null
+ repositoryUrl = [string]$Record.repositoryUrl
+ repositoryCommit = [string]$Record.repositoryCommit
+ authors = $null
+ }
+ }
+
+ [xml]$nuspecXml = Get-Content -LiteralPath $nuspec.FullName
+ $licenseNode = $nuspecXml.SelectSingleNode("//*[local-name()='metadata']/*[local-name()='license']")
+ $licenseUrlNode = $nuspecXml.SelectSingleNode("//*[local-name()='metadata']/*[local-name()='licenseUrl']")
+ $repositoryNode = $nuspecXml.SelectSingleNode("//*[local-name()='metadata']/*[local-name()='repository']")
+ $authorsNode = $nuspecXml.SelectSingleNode("//*[local-name()='metadata']/*[local-name()='authors']")
+
+ $licenseType = 'missing'
+ $licenseValue = $null
+ $declaredLicense = 'UNKNOWN'
+ $licenseEvidenceSha256 = $null
+ if ($null -ne $licenseNode) {
+ $licenseType = [string]$licenseNode.type
+ $licenseValue = ([string]$licenseNode.InnerText).Trim()
+ if ($licenseType -ieq 'expression') {
+ $declaredLicense = $licenseValue
+ }
+ elseif ($licenseType -ieq 'file') {
+ $declaredLicense = 'LicenseRef-NuGet-License-File'
+ $licensePath = Join-Path $rootPath $licenseValue.Replace('/', [System.IO.Path]::DirectorySeparatorChar)
+ if ([System.IO.File]::Exists($licensePath)) {
+ $licenseEvidenceSha256 = Get-FileSha256 $licensePath
+ }
+ }
+ }
+ elseif ($null -ne $licenseUrlNode -and
+ -not [string]::IsNullOrWhiteSpace([string]$licenseUrlNode.InnerText)) {
+ $licenseType = 'url'
+ $licenseValue = ([string]$licenseUrlNode.InnerText).Trim()
+ $declaredLicense = 'LicenseRef-NuGet-License-URL'
+ }
+
+ $packageSha512 = [string]$Record.packageSha512
+ if ([string]::IsNullOrWhiteSpace($packageSha512)) {
+ $shaFile = Get-ChildItem -LiteralPath $rootPath -File -Filter '*.nupkg.sha512' |
+ Select-Object -First 1
+ if ($null -ne $shaFile) {
+ $packageSha512 = Convert-Base64Sha512ToHex (
+ Get-Content -LiteralPath $shaFile.FullName -Raw)
+ }
+ }
+
+ return [ordered]@{
+ evidenceType = 'nuget-package'
+ packageSha512 = $packageSha512
+ nuspecSha256 = Get-FileSha256 $nuspec.FullName
+ declaredLicense = $declaredLicense
+ licenseType = $licenseType
+ licenseValue = $licenseValue
+ licenseEvidenceSha256 = $licenseEvidenceSha256
+ repositoryUrl = if ($null -ne $repositoryNode) { [string]$repositoryNode.url } else { [string]$Record.repositoryUrl }
+ repositoryCommit = if ($null -ne $repositoryNode) { [string]$repositoryNode.commit } else { [string]$Record.repositoryCommit }
+ authors = if ($null -ne $authorsNode) { ([string]$authorsNode.InnerText).Trim() } else { $null }
+ }
+}
+
+function Get-ExpectedVersion([object]$Entry) {
+ if ($null -eq $Entry) {
+ return $null
+ }
+ $versionSource = [string]$Entry.expectedVersionSource
+ if ($versionSource -eq 'product') { return $productVersion }
+ if ($versionSource -eq 'sdk') { return $sdkVersion }
+ if ($versionSource -eq 'runtime') { return $runtimeVersion }
+ return [string]$Entry.expectedVersion
+}
+
+function Resolve-LicenseDecision(
+ [System.Collections.IDictionary]$Record,
+ [System.Collections.IDictionary]$Evidence,
+ [object]$PolicyEntry) {
+ $declared = [string]$Evidence.declaredLicense
+ $concluded = $declared
+ $decisionSource = 'package-metadata'
+
+ if (-not [string]::IsNullOrWhiteSpace([string]$Record.coverageLicense)) {
+ $concluded = [string]$Record.coverageLicense
+ $decisionSource = 'component-coverage'
+ }
+ elseif ($null -ne $PolicyEntry -and
+ -not [string]::IsNullOrWhiteSpace([string]$PolicyEntry.license) -and
+ ($declared -like 'LicenseRef-NuGet-*' -or $declared -eq 'UNKNOWN')) {
+ $concluded = [string]$PolicyEntry.license
+ $decisionSource = 'reviewed-policy-override'
+ }
+ elseif ($null -ne $PolicyEntry -and
+ -not [string]::IsNullOrWhiteSpace([string]$PolicyEntry.license) -and
+ [string]$PolicyEntry.license -ne $declared) {
+ $script:failures.Add(
+ "Package '$($Record.identity)' declares '$declared' but policy expects '$($PolicyEntry.license)'.")
+ }
+
+ if ([string]::IsNullOrWhiteSpace($concluded)) {
+ $concluded = 'UNKNOWN'
+ }
+ $status = 'approved'
+ if ($concluded -in @('UNKNOWN', 'NOASSERTION')) {
+ $script:failures.Add("Dependency '$($Record.identity)' has no resolved license.")
+ $status = 'failed'
+ }
+ elseif (Test-AnyPattern $concluded $deniedLicenses) {
+ $script:failures.Add("Dependency '$($Record.identity)' uses denied license '$concluded'.")
+ $status = 'failed'
+ }
+ elseif (Test-AnyPattern $concluded $allowedLicenses) {
+ $status = 'approved'
+ }
+ elseif (Test-AnyPattern $concluded $reviewLicenses) {
+ $script:failures.Add("Dependency '$($Record.identity)' requires explicit license review for '$concluded'.")
+ $status = 'failed'
+ }
+ else {
+ $script:failures.Add("Dependency '$($Record.identity)' uses unreviewed license '$concluded'.")
+ $status = 'failed'
+ }
+
+ return [ordered]@{
+ declared = $declared
+ concluded = $concluded
+ decisionSource = $decisionSource
+ status = $status
+ }
+}
+
+$components = [System.Collections.Generic.List[object]]::new()
+foreach ($recordKey in @($script:packageRecords.Keys | Sort-Object)) {
+ $record = $script:packageRecords[$recordKey]
+ $policyEntry = Get-PolicyEntry ([string]$record.name)
+ $expectedVersion = Get-ExpectedVersion $policyEntry
+ if (-not [string]::IsNullOrWhiteSpace($expectedVersion) -and
+ [string]$record.version -ne $expectedVersion) {
+ $script:failures.Add(
+ "Dependency '$($record.name)' has version '$($record.version)'; reviewed version is '$expectedVersion'.")
+ }
+
+ $isManual = [string]$record.name -eq 'Microsoft .NET SDK' -or
+ ([string]$record.name).StartsWith('actions/', [StringComparison]::OrdinalIgnoreCase)
+ $evidence = if ($isManual) {
+ [ordered]@{
+ evidenceType = 'reviewed-policy'
+ packageSha512 = if ([string]$record.name -eq 'Microsoft .NET SDK') { [string]$noticeMetadata.source.packageSha512 } else { $null }
+ nuspecSha256 = $null
+ declaredLicense = if ($null -ne $policyEntry) { [string]$policyEntry.license } else { 'UNKNOWN' }
+ licenseType = 'policy'
+ licenseValue = if ($null -ne $policyEntry) { [string]$policyEntry.license } else { $null }
+ licenseEvidenceSha256 = $policySha256
+ repositoryUrl = if ($null -ne $policyEntry) { [string]$policyEntry.source } else { $null }
+ repositoryCommit = [string]$record.version
+ authors = if ($null -ne $policyEntry) { [string]$policyEntry.supplier } else { $null }
+ }
+ }
+ else {
+ Get-NuGetEvidence $record
+ }
+
+ $licenseDecision = Resolve-LicenseDecision $record $evidence $policyEntry
+ if ([string]$evidence.packageSha512 -notmatch '^[0-9A-Fa-f]{128}$' -and -not $isManual) {
+ $script:failures.Add("Dependency '$($record.identity)' lacks exact package SHA-512 evidence.")
+ }
+ if ([string]$evidence.nuspecSha256 -notmatch '^[0-9A-Fa-f]{64}$' -and -not $isManual) {
+ $script:failures.Add("Dependency '$($record.identity)' lacks exact .nuspec SHA-256 evidence.")
+ }
+
+ $supplier = if ($null -ne $policyEntry -and
+ -not [string]::IsNullOrWhiteSpace([string]$policyEntry.supplier)) {
+ [string]$policyEntry.supplier
+ }
+ elseif (-not [string]::IsNullOrWhiteSpace([string]$evidence.authors)) {
+ "Organization: $($evidence.authors)"
+ }
+ else {
+ 'NOASSERTION'
+ }
+ $source = if ($null -ne $policyEntry -and
+ -not [string]::IsNullOrWhiteSpace([string]$policyEntry.source)) {
+ [string]$policyEntry.source
+ }
+ elseif (-not [string]::IsNullOrWhiteSpace([string]$evidence.repositoryUrl)) {
+ [string]$evidence.repositoryUrl
+ }
+ elseif (-not [string]::IsNullOrWhiteSpace([string]$record.packageUrl)) {
+ [string]$record.packageUrl
+ }
+ else {
+ "https://www.nuget.org/packages/$($record.name)/$($record.version)"
+ }
+
+ $scope = Get-PrimaryScope $record
+ $components.Add([ordered]@{
+ name = [string]$record.name
+ version = [string]$record.version
+ identity = [string]$record.identity
+ scope = $scope
+ scopes = @($record.scopes | Sort-Object)
+ graphs = @($record.graphs | Sort-Object)
+ direct = [bool]$record.direct
+ transitive = -not [bool]$record.direct
+ shipped = [bool]$record.shipped
+ supplier = $supplier
+ licenseDeclared = [string]$licenseDecision.declared
+ licenseConcluded = [string]$licenseDecision.concluded
+ licenseDecisionSource = [string]$licenseDecision.decisionSource
+ source = $source
+ repositoryCommit = [string]$evidence.repositoryCommit
+ purpose = if ($scope -eq 'shipped') { 'LIBRARY' } else { 'OTHER' }
+ status = [string]$licenseDecision.status
+ evidence = $evidence
+ })
+}
+
+$sightAdaptComponent = [ordered]@{
+ name = 'SightAdapt'
+ version = $productVersion
+ identity = "SightAdapt/$productVersion"
+ scope = 'shipped'
+ scopes = @('shipped')
+ graphs = @('application')
+ direct = $true
+ transitive = $false
+ shipped = $true
+ supplier = 'Organization: KeyffMS / aiteracja.pl'
+ licenseDeclared = 'MIT'
+ licenseConcluded = 'MIT'
+ licenseDecisionSource = 'repository-license'
+ source = 'https://github.com/KeyffMS/SightAdapt'
+ repositoryCommit = $null
+ purpose = 'APPLICATION'
+ status = 'approved'
+ evidence = [ordered]@{
+ evidenceType = 'repository-license'
+ packageSha512 = $null
+ nuspecSha256 = $null
+ declaredLicense = 'MIT'
+ licenseType = 'file'
+ licenseValue = 'LICENSE'
+ licenseEvidenceSha256 = Get-FileSha256 (Join-Path $root 'LICENSE')
+ repositoryUrl = 'https://github.com/KeyffMS/SightAdapt'
+ repositoryCommit = $null
+ authors = 'KeyffMS / aiteracja.pl'
+ }
+}
+$components.Add($sightAdaptComponent)
+
$summaryLines = [System.Collections.Generic.List[string]]::new()
$summaryLines.Add('# SightAdapt dependency inventory')
$summaryLines.Add('')
-$summaryLines.Add(
- "Generated from the release dependency policy, the actual restore graph and exact .NET notice metadata for SightAdapt $productVersion ($rid).")
+$summaryLines.Add("Generated from the complete application and test restore graphs, exact publish-component coverage and reviewed build-tool policy for SightAdapt $productVersion ($rid).")
$summaryLines.Add('')
-$summaryLines.Add('| Component | Version | Scope | Shipped | Supplier | License | Source |')
-$summaryLines.Add('|---|---|---|---|---|---|---|')
-foreach ($component in @($components | Sort-Object scope, name)) {
- $source = if ([string]::IsNullOrWhiteSpace($component.source)) {
- 'not recorded'
- }
- else {
- $component.source
+$summaryLines.Add('| Component | Version | Scope | Direct | Shipped | License | Evidence | Source |')
+$summaryLines.Add('|---|---|---|---:|---:|---|---|---|')
+foreach ($component in @($components | Sort-Object scope, name, version)) {
+ $evidenceLabel = [string]$component.evidence.evidenceType
+ if ([string]$component.evidence.nuspecSha256 -match '^[0-9A-Fa-f]{64}$') {
+ $evidenceLabel += "; nuspec SHA-256 `$([string]$component.evidence.nuspecSha256)`"
}
$summaryLines.Add(
- "| $($component.name) | `$($component.version)` | $($component.scope) | $($component.shipped) | $($component.supplier) | `$($component.license)` | $source |")
+ "| $($component.name) | `$($component.version)` | $($component.scope) | $($component.direct) | $($component.shipped) | `$($component.licenseConcluded)` | $evidenceLabel | $($component.source) |")
}
$summaryLines.Add('')
-$summaryLines.Add('`SBOM.spdx.json` contains the machine-readable component and file inventory. `LICENSE-REPORT.json` records the policy result. Build and test dependencies are not included in the binary package unless `shipped` is `true`.')
-$summaryText = $summaryLines -join [Environment]::NewLine
+$summaryLines.Add('`SBOM.spdx.json` contains the same package inventory, dependency graph and packaged-file hashes. `LICENSE-REPORT.json` contains the complete evidence and policy result. Build and test components are not represented as runtime dependencies of SightAdapt.')
[System.IO.File]::WriteAllText(
(Join-Path $publish 'DEPENDENCIES.md'),
- $summaryText + [Environment]::NewLine,
+ ($summaryLines -join [Environment]::NewLine) + [Environment]::NewLine,
[System.Text.UTF8Encoding]::new($false))
$created = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
+$applicationPackages = @($components | Where-Object { @($_.graphs) -contains 'application' })
+$testPackages = @($components | Where-Object { @($_.graphs) -contains 'test' })
+$transitivePackages = @($components | Where-Object { $_.transitive -and $_.name -ne 'SightAdapt' })
$report = [ordered]@{
- schemaVersion = 1
+ schemaVersion = 2
generatedAtUtc = $created
productVersion = $productVersion
sdkVersion = $sdkVersion
runtimeVersion = $runtimeVersion
runtimeIdentifier = $rid
policy = 'release/dependency-policy.json'
- result = if ($failures.Count -eq 0) { 'pass' } else { 'fail' }
+ policySha256 = $policySha256
+ result = if ($script:failures.Count -eq 0) { 'pass' } else { 'fail' }
+ inventory = [ordered]@{
+ applicationPackageCount = $applicationPackages.Count
+ testPackageCount = $testPackages.Count
+ transitivePackageCount = $transitivePackages.Count
+ shippedPackageCount = @($components | Where-Object { $_.shipped }).Count
+ packageCount = $components.Count
+ graphEdgeCount = $script:graphEdges.Count
+ sources = @(
+ 'src/SightAdapt/obj/project.assets.json',
+ 'tests/SightAdapt.Tests/obj/project.assets.json',
+ 'DOTNET-NOTICE-METADATA.json',
+ '.github/workflows/build.yml'
+ )
+ }
allowedLicenseExpressions = $allowedLicenses
deniedLicenseExpressions = $deniedLicenses
- components = @($components | Sort-Object scope, name)
reviewLicenseExpressions = $reviewLicenses
- failures = @($failures)
+ components = @($components | Sort-Object scope, name, version)
+ dependencyEdges = @($script:graphEdges | Sort-Object graph, from, to)
+ failures = @($script:failures)
}
[System.IO.File]::WriteAllText(
(Join-Path $publish 'LICENSE-REPORT.json'),
- ($report | ConvertTo-Json -Depth 8) + [Environment]::NewLine,
+ ($report | ConvertTo-Json -Depth 14) + [Environment]::NewLine,
[System.Text.UTF8Encoding]::new($false))
function Get-SpdxId([string]$Prefix, [string]$Value) {
@@ -263,23 +722,20 @@ function Get-SpdxId([string]$Prefix, [string]$Value) {
$files = [System.Collections.Generic.List[object]]::new()
$fileRelationships = [System.Collections.Generic.List[object]]::new()
-$sightAdaptId = Get-SpdxId 'Package' 'SightAdapt'
+$sightAdaptId = Get-SpdxId 'Package' "SightAdapt-$productVersion"
foreach ($file in @(Get-ChildItem -LiteralPath $publish -File -Recurse | Sort-Object FullName)) {
$relative = [System.IO.Path]::GetRelativePath($publish, $file.FullName).Replace('\', '/')
if ($relative -eq 'SBOM.spdx.json') {
continue
}
$fileId = Get-SpdxId 'File' $relative
- $hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
$files.Add([ordered]@{
fileName = "./$relative"
SPDXID = $fileId
- checksums = @(
- [ordered]@{
- algorithm = 'SHA256'
- checksumValue = $hash
- }
- )
+ checksums = @([ordered]@{
+ algorithm = 'SHA256'
+ checksumValue = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
+ })
licenseConcluded = 'NOASSERTION'
copyrightText = 'NOASSERTION'
})
@@ -292,82 +748,99 @@ foreach ($file in @(Get-ChildItem -LiteralPath $publish -File -Recurse | Sort-Ob
$packages = [System.Collections.Generic.List[object]]::new()
$relationships = [System.Collections.Generic.List[object]]::new()
-foreach ($component in @($components | Sort-Object name)) {
- $packageId = Get-SpdxId 'Package' $component.name
+$packageIdByIdentity = @{}
+foreach ($component in @($components | Sort-Object name, version)) {
+ $packageId = Get-SpdxId 'Package' "$($component.name)-$($component.version)"
+ $identityKey = ([string]$component.identity).ToLowerInvariant()
+ $packageIdByIdentity[$identityKey] = $packageId
$package = [ordered]@{
name = $component.name
SPDXID = $packageId
versionInfo = $component.version
supplier = $component.supplier
- downloadLocation = if ([string]::IsNullOrWhiteSpace($component.source)) {
- 'NOASSERTION'
- }
- else {
- $component.source
- }
+ downloadLocation = if ([string]::IsNullOrWhiteSpace([string]$component.source)) { 'NOASSERTION' } else { $component.source }
filesAnalyzed = $false
- licenseConcluded = $component.license
- licenseDeclared = $component.license
+ licenseConcluded = $component.licenseConcluded
+ licenseDeclared = $component.licenseDeclared
copyrightText = 'NOASSERTION'
primaryPackagePurpose = $component.purpose
- comment = "Scope: $($component.scope); shipped: $($component.shipped)."
+ comment = "Scope: $($component.scope); direct: $($component.direct); transitive: $($component.transitive); shipped: $($component.shipped); evidence: $($component.evidence.evidenceType)."
}
-
- if ($component.name -eq 'SightAdapt') {
- $exePath = Join-Path $publish 'SightAdapt.exe'
- if ([System.IO.File]::Exists($exePath)) {
- $package['checksums'] = @(
- [ordered]@{
- algorithm = 'SHA256'
- checksumValue = (Get-FileHash -LiteralPath $exePath -Algorithm SHA256).Hash
- }
- )
- }
+ if ([string]$component.evidence.packageSha512 -match '^[0-9A-Fa-f]{128}$') {
+ $package['checksums'] = @([ordered]@{
+ algorithm = 'SHA512'
+ checksumValue = [string]$component.evidence.packageSha512
+ })
}
- elseif ($component.name -eq 'Microsoft .NET SDK' -and
- [string]$noticeMetadata.source.packageSha512 -match '^[0-9A-Fa-f]{128}$') {
- $package['checksums'] = @(
- [ordered]@{
- algorithm = 'SHA512'
- checksumValue = [string]$noticeMetadata.source.packageSha512
- }
- )
+ elseif ($component.name -eq 'SightAdapt') {
+ $package['checksums'] = @([ordered]@{
+ algorithm = 'SHA256'
+ checksumValue = (Get-FileHash -LiteralPath (Join-Path $publish 'SightAdapt.exe') -Algorithm SHA256).Hash
+ })
}
if ($component.name -like 'actions/*') {
- $package['externalRefs'] = @(
- [ordered]@{
- referenceCategory = 'PACKAGE-MANAGER'
- referenceType = 'purl'
- referenceLocator = "pkg:github/$($component.name)@$($component.version)"
- }
- )
+ $package['externalRefs'] = @([ordered]@{
+ referenceCategory = 'PACKAGE-MANAGER'
+ referenceType = 'purl'
+ referenceLocator = "pkg:github/$($component.name)@$($component.version)"
+ })
}
- elseif ($component.name -ne 'SightAdapt' -and
- $component.name -ne 'Microsoft .NET SDK') {
- $package['externalRefs'] = @(
- [ordered]@{
- referenceCategory = 'PACKAGE-MANAGER'
- referenceType = 'purl'
- referenceLocator = "pkg:nuget/$($component.name)@$($component.version)"
- }
- )
+ elseif ($component.name -ne 'SightAdapt' -and $component.name -ne 'Microsoft .NET SDK') {
+ $package['externalRefs'] = @([ordered]@{
+ referenceCategory = 'PACKAGE-MANAGER'
+ referenceType = 'purl'
+ referenceLocator = "pkg:nuget/$($component.name)@$($component.version)"
+ })
}
-
$packages.Add($package)
- if ($component.name -eq 'SightAdapt') {
- $relationships.Add([ordered]@{
- spdxElementId = 'SPDXRef-DOCUMENT'
- relationshipType = 'DESCRIBES'
+}
+
+$relationships.Add([ordered]@{
+ spdxElementId = 'SPDXRef-DOCUMENT'
+ relationshipType = 'DESCRIBES'
+ relatedSpdxElement = $sightAdaptId
+})
+$rootRelationshipKeys = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+foreach ($component in @($components | Where-Object { $_.name -ne 'SightAdapt' })) {
+ $identityKey = ([string]$component.identity).ToLowerInvariant()
+ $packageId = $packageIdByIdentity[$identityKey]
+ if ($component.shipped) {
+ $relationship = [ordered]@{
+ spdxElementId = $sightAdaptId
+ relationshipType = 'DEPENDS_ON'
relatedSpdxElement = $packageId
- })
+ comment = 'Published runtime/package dependency.'
+ }
+ }
+ elseif ($component.scope -eq 'test') {
+ $relationship = [ordered]@{
+ spdxElementId = $packageId
+ relationshipType = 'TEST_DEPENDENCY_OF'
+ relatedSpdxElement = $sightAdaptId
+ }
}
else {
+ $relationship = [ordered]@{
+ spdxElementId = $packageId
+ relationshipType = 'BUILD_DEPENDENCY_OF'
+ relatedSpdxElement = $sightAdaptId
+ }
+ }
+ $relationshipKey = "$($relationship.spdxElementId)|$($relationship.relationshipType)|$($relationship.relatedSpdxElement)"
+ if ($rootRelationshipKeys.Add($relationshipKey)) {
+ $relationships.Add($relationship)
+ }
+}
+foreach ($edge in @($script:graphEdges)) {
+ $fromKey = ([string]$edge.from).ToLowerInvariant()
+ $toKey = ([string]$edge.to).ToLowerInvariant()
+ if ($packageIdByIdentity.ContainsKey($fromKey) -and $packageIdByIdentity.ContainsKey($toKey)) {
$relationships.Add([ordered]@{
- spdxElementId = $sightAdaptId
+ spdxElementId = $packageIdByIdentity[$fromKey]
relationshipType = 'DEPENDS_ON'
- relatedSpdxElement = $packageId
- comment = "Dependency scope: $($component.scope); shipped: $($component.shipped)."
+ relatedSpdxElement = $packageIdByIdentity[$toKey]
+ comment = "Restore graph: $($edge.graph)."
})
}
}
@@ -375,6 +848,17 @@ foreach ($relationship in $fileRelationships) {
$relationships.Add($relationship)
}
+$extractedLicenses = [System.Collections.Generic.List[object]]::new()
+foreach ($referenceProperty in @($policy.licenseReferences.PSObject.Properties)) {
+ $reference = $referenceProperty.Value
+ $extractedLicenses.Add([ordered]@{
+ licenseId = [string]$referenceProperty.Name
+ name = [string]$reference.name
+ extractedText = [string]$reference.extractedText
+ seeAlsos = @($reference.seeAlsos)
+ })
+}
+
$namespaceId = [Guid]::NewGuid().ToString('N')
$sbom = [ordered]@{
spdxVersion = 'SPDX-2.3'
@@ -388,40 +872,27 @@ $sbom = [ordered]@{
'Tool: SightAdapt tools/generate-sbom.ps1',
'Organization: KeyffMS / aiteracja.pl'
)
+ comment = 'Inventory derived from complete application/test NuGet restore graphs, exact publish-component coverage and reviewed build-tool policy.'
}
documentDescribes = @($sightAdaptId)
packages = @($packages)
files = @($files)
relationships = @($relationships)
- hasExtractedLicensingInfos = @(
- [ordered]@{
- licenseId = 'LicenseRef-Microsoft-DotNet-Distribution'
- name = 'Microsoft .NET distribution terms and component notices'
- extractedText = 'See DOTNET-LICENSE-NOTICE.txt, THIRD-PARTY-NOTICES.txt, MICROSOFT-DOTNET-REDISTRIBUTION.txt and DOTNET-NOTICE-METADATA.json in the same package.'
- seeAlsos = @(
- 'https://dotnet.microsoft.com/en-us/dotnet_library_license.htm',
- 'https://github.com/dotnet/core/blob/main/license-information.md'
- )
- }
- [ordered]@{
- licenseId = 'LicenseRef-Microsoft-Windows-SDK-Reference-Terms'
- name = 'Microsoft Windows SDK .NET reference package terms'
- extractedText = 'Build-time reference package only; not shipped. Review the package terms and metadata at the recorded source URL.'
- seeAlsos = @(
- 'https://www.nuget.org/packages/Microsoft.Windows.SDK.NET.Ref'
- )
- }
- )
+ hasExtractedLicensingInfos = @($extractedLicenses)
}
-
[System.IO.File]::WriteAllText(
(Join-Path $publish 'SBOM.spdx.json'),
- ($sbom | ConvertTo-Json -Depth 12) + [Environment]::NewLine,
+ ($sbom | ConvertTo-Json -Depth 16) + [Environment]::NewLine,
[System.Text.UTF8Encoding]::new($false))
-if ($failures.Count -gt 0) {
- $details = $failures | ForEach-Object { " - $_" }
+if ($script:failures.Count -gt 0) {
+ $details = $script:failures | ForEach-Object { " - $_" }
throw "Dependency license review failed:`n$($details -join "`n")"
}
-Write-Host "Generated SPDX 2.3 SBOM and approved license report for $($components.Count) components and $($files.Count) packaged files."
+Write-Host (
+ "Generated SPDX 2.3 SBOM for {0} components ({1} transitive), {2} graph edges and {3} packaged files." -f
+ $components.Count,
+ $transitivePackages.Count,
+ $script:graphEdges.Count,
+ $files.Count)
From 2c5d7e86960c6b74ecc230770a1271c2c5011473 Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:53:19 +0200
Subject: [PATCH 02/10] Make dependency policy an override layer
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
release/dependency-policy.json | 55 +++++++++++++++++++++++++++-------
1 file changed, 45 insertions(+), 10 deletions(-)
diff --git a/release/dependency-policy.json b/release/dependency-policy.json
index c3372e0f..208da5ee 100644
--- a/release/dependency-policy.json
+++ b/release/dependency-policy.json
@@ -1,7 +1,15 @@
{
- "schemaVersion": 1,
+ "schemaVersion": 2,
+ "inventoryMode": "automatic-from-complete-restore-graphs-and-publish-evidence",
"allowedLicenseExpressions": [
"MIT",
+ "Apache-2.0",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "ISC",
+ "MS-PL",
+ "Zlib",
+ "Unicode-3.0",
"LicenseRef-Microsoft-DotNet-Distribution",
"LicenseRef-Microsoft-Windows-SDK-Reference-Terms"
],
@@ -13,6 +21,31 @@
"NOASSERTION",
"UNKNOWN"
],
+ "reviewLicenseExpressions": [
+ "LGPL-*",
+ "MPL-*",
+ "EPL-*",
+ "CDDL-*",
+ "LicenseRef-*"
+ ],
+ "licenseReferences": {
+ "LicenseRef-Microsoft-DotNet-Distribution": {
+ "name": "Microsoft .NET distribution terms and component notices",
+ "extractedText": "See DOTNET-LICENSE-NOTICE.txt, THIRD-PARTY-NOTICES.txt, MICROSOFT-DOTNET-REDISTRIBUTION.txt and DOTNET-NOTICE-METADATA.json in the same package.",
+ "seeAlsos": [
+ "https://dotnet.microsoft.com/en-us/dotnet_library_license.htm",
+ "https://github.com/dotnet/core/blob/main/license-information.md"
+ ]
+ },
+ "LicenseRef-Microsoft-Windows-SDK-Reference-Terms": {
+ "name": "Microsoft Windows SDK .NET package terms",
+ "extractedText": "The exact Microsoft.Windows.SDK.NET.Ref package contributes embedded assemblies to SightAdapt.exe. Its package SHA-512, nuspec SHA-256, license URL and component hashes are retained in DOTNET-NOTICE-METADATA.json and THIRD-PARTY-NOTICES.txt.",
+ "seeAlsos": [
+ "https://aka.ms/WinSDKLicenseURL",
+ "https://www.nuget.org/packages/Microsoft.Windows.SDK.NET.Ref"
+ ]
+ }
+ },
"components": {
"SightAdapt": {
"expectedVersionSource": "product",
@@ -54,6 +87,16 @@
"purpose": "LIBRARY",
"expectedVersion": ""
},
+ "Microsoft.NET.ILLink.Tasks": {
+ "expectedVersionSource": "runtime",
+ "scope": "application-build",
+ "shipped": false,
+ "supplier": "Organization: Microsoft Corporation",
+ "license": "MIT",
+ "source": "https://github.com/dotnet/runtime",
+ "purpose": "OTHER",
+ "expectedVersion": ""
+ },
"Microsoft.NET.Test.Sdk": {
"expectedVersion": "17.11.1",
"scope": "test",
@@ -134,13 +177,5 @@
"source": "https://www.nuget.org/packages/Microsoft.Windows.SDK.NET.Ref",
"purpose": "LIBRARY"
}
- },
- "reviewLicenseExpressions": [
- "LGPL-2.1-only",
- "LGPL-3.0-only",
- "MPL-2.0",
- "EPL-2.0",
- "CDDL-1.0",
- "LicenseRef-*"
- ]
+ }
}
From 95c451e374d3000289f6dc766c6debc3dc0dd16d Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:53:53 +0200
Subject: [PATCH 03/10] Reject unknown transitive dependency licenses
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
tools/test-sbom-license-negative.ps1 | 137 +++++++++++++++++++++++++++
1 file changed, 137 insertions(+)
create mode 100644 tools/test-sbom-license-negative.ps1
diff --git a/tools/test-sbom-license-negative.ps1 b/tools/test-sbom-license-negative.ps1
new file mode 100644
index 00000000..aaee562c
--- /dev/null
+++ b/tools/test-sbom-license-negative.ps1
@@ -0,0 +1,137 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateNotNullOrEmpty()]
+ [string]$PublishDirectory,
+
+ [string]$TestAssetsPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+$root = Split-Path -Parent $PSScriptRoot
+if ([string]::IsNullOrWhiteSpace($TestAssetsPath)) {
+ $TestAssetsPath = Join-Path $root 'tests\SightAdapt.Tests\obj\project.assets.json'
+}
+
+$resolvedPublish = (Resolve-Path -LiteralPath $PublishDirectory).Path
+$resolvedTestAssets = (Resolve-Path -LiteralPath $TestAssetsPath).Path
+$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) (
+ 'sightadapt-sbom-negative-' + [Guid]::NewGuid().ToString('N'))
+
+try {
+ $fixturePublish = Join-Path $tempRoot 'publish'
+ $packageFolder = Join-Path $tempRoot 'packages'
+ [System.IO.Directory]::CreateDirectory($fixturePublish) | Out-Null
+ [System.IO.Directory]::CreateDirectory($packageFolder) | Out-Null
+ Copy-Item -Path (Join-Path $resolvedPublish '*') `
+ -Destination $fixturePublish `
+ -Recurse `
+ -Force
+
+ $packageId = 'Unknown.Transitive'
+ $packageVersion = '1.0.0'
+ $identity = "$packageId/$packageVersion"
+ $packageRelativePath = 'unknown.transitive/1.0.0'
+ $packageRoot = Join-Path $packageFolder 'unknown.transitive\1.0.0'
+ [System.IO.Directory]::CreateDirectory($packageRoot) | Out-Null
+
+ $nuspec = @"
+
+
+
+ $packageId
+ $packageVersion
+ Unknown Publisher
+ Deliberately missing license metadata.
+
+
+"@
+ [System.IO.File]::WriteAllText(
+ (Join-Path $packageRoot 'unknown.transitive.nuspec'),
+ $nuspec,
+ [System.Text.UTF8Encoding]::new($false))
+ $sha512Base64 = [Convert]::ToBase64String([byte[]]::new(64))
+ [System.IO.File]::WriteAllText(
+ (Join-Path $packageRoot 'unknown.transitive.1.0.0.nupkg.sha512'),
+ $sha512Base64,
+ [System.Text.UTF8Encoding]::new($false))
+
+ $assets = Get-Content -LiteralPath $resolvedTestAssets -Raw | ConvertFrom-Json
+ $folderPropertyName = $packageFolder.TrimEnd('\') + '\'
+ $assets.packageFolders | Add-Member `
+ -NotePropertyName $folderPropertyName `
+ -NotePropertyValue ([pscustomobject]@{}) `
+ -Force
+ $assets.libraries | Add-Member `
+ -NotePropertyName $identity `
+ -NotePropertyValue ([pscustomobject]@{
+ sha512 = $sha512Base64
+ type = 'package'
+ path = $packageRelativePath
+ files = @('unknown.transitive.nuspec')
+ }) `
+ -Force
+
+ $targetProperty = @($assets.targets.PSObject.Properties) | Select-Object -First 1
+ if ($null -eq $targetProperty) {
+ throw 'The test restore graph has no target for the transitive-license fixture.'
+ }
+ $targetProperty.Value | Add-Member `
+ -NotePropertyName $identity `
+ -NotePropertyValue ([pscustomobject]@{
+ type = 'package'
+ compile = [pscustomobject]@{}
+ runtime = [pscustomobject]@{}
+ }) `
+ -Force
+
+ $parentProperty = @(
+ $targetProperty.Value.PSObject.Properties |
+ Where-Object { [string]$_.Value.type -eq 'package' }
+ ) | Select-Object -First 1
+ if ($null -eq $parentProperty) {
+ throw 'The test restore graph has no package node for the transitive-license fixture.'
+ }
+ $dependenciesProperty = $parentProperty.Value.PSObject.Properties['dependencies']
+ if ($null -eq $dependenciesProperty) {
+ $parentProperty.Value | Add-Member `
+ -NotePropertyName 'dependencies' `
+ -NotePropertyValue ([pscustomobject]@{})
+ }
+ $parentProperty.Value.dependencies | Add-Member `
+ -NotePropertyName $packageId `
+ -NotePropertyValue $packageVersion `
+ -Force
+
+ $fixtureAssets = Join-Path $tempRoot 'project.assets.json'
+ [System.IO.File]::WriteAllText(
+ $fixtureAssets,
+ ($assets | ConvertTo-Json -Depth 100),
+ [System.Text.UTF8Encoding]::new($false))
+
+ $failedAsExpected = $false
+ try {
+ & (Join-Path $PSScriptRoot 'generate-sbom.ps1') `
+ -PublishDirectory $fixturePublish `
+ -TestAssetsPath $fixtureAssets
+ }
+ catch {
+ if ($_.Exception.Message -notmatch 'Unknown\.Transitive' -or
+ $_.Exception.Message -notmatch 'no resolved license') {
+ throw "The transitive-license fixture failed for an unexpected reason: $($_.Exception.Message)"
+ }
+ $failedAsExpected = $true
+ Write-Host "Unknown transitive license was rejected as expected: $($_.Exception.Message)"
+ }
+
+ if (-not $failedAsExpected) {
+ throw 'A transitive package with no license metadata unexpectedly passed SBOM/license review.'
+ }
+}
+finally {
+ Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+Write-Host 'Negative transitive dependency-license test passed.'
From e7b23fa3ce21b8d5be40d50cb1ab26ad2809a527 Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:54:29 +0200
Subject: [PATCH 04/10] Validate complete restore graphs in CI
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
.github/workflows/build.yml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 68a6e12f..faa45cb0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -72,6 +72,7 @@ jobs:
test-output.txt
TestResults/SightAdapt.trx
src/SightAdapt/obj/project.assets.json
+ tests/SightAdapt.Tests/obj/project.assets.json
if-no-files-found: warn
retention-days: 7
@@ -106,6 +107,12 @@ jobs:
.\tools\generate-sbom.ps1
-PublishDirectory artifacts/win-x64
+ - name: Verify unknown transitive licenses are rejected
+ shell: pwsh
+ run: >-
+ .\tools\test-sbom-license-negative.ps1
+ -PublishDirectory artifacts/win-x64
+
- name: Verify incomplete, stale and unmapped packages are rejected
shell: pwsh
run: >-
From 53b1757b8ccfa04aaf7a66dd0a9ca2ef813a8ce5 Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:56:17 +0200
Subject: [PATCH 05/10] Document complete SBOM and license evidence
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
docs/legal/SBOM-AND-LICENSE-REVIEW.md | 83 +++++++++++++++++----------
1 file changed, 54 insertions(+), 29 deletions(-)
diff --git a/docs/legal/SBOM-AND-LICENSE-REVIEW.md b/docs/legal/SBOM-AND-LICENSE-REVIEW.md
index d18c2e0c..856ae709 100644
--- a/docs/legal/SBOM-AND-LICENSE-REVIEW.md
+++ b/docs/legal/SBOM-AND-LICENSE-REVIEW.md
@@ -2,57 +2,82 @@
## Scope
-Every SightAdapt release candidate produces a machine-readable SPDX 2.3 software bill of materials and a machine-readable license-policy report. These files describe the published application container, exact .NET runtime packs, application/test/build dependencies and every file placed in the final publish directory.
+Every SightAdapt release candidate produces a machine-readable SPDX 2.3 software bill of materials, a machine-readable license-evidence report and a human-readable dependency summary. The inventory covers the published application, exact embedded/runtime packages, every application package, the complete test graph, SDK/build inputs and GitHub Actions used by the maintained workflow.
-This process is supply-chain and release documentation. It does not change SightAdapt application behavior.
+This process is supply-chain and release documentation. It does not change SightAdapt application behavior and does not claim that an internal license decision is external legal advice.
-## Authoritative inputs
+## Authoritative inventory inputs
`tools/generate-sbom.ps1` reads:
-- `Directory.Build.props` for the product, SDK, runtime and RID versions;
-- `src/SightAdapt/obj/project.assets.json` for the actual application restore graph;
-- `DOTNET-NOTICE-METADATA.json` for exact runtime-pack and Microsoft source-package evidence;
-- application and test project files for direct NuGet references;
-- `.github/workflows/build.yml` for GitHub Actions build dependencies;
-- `release/dependency-policy.json` for reviewed suppliers, licenses, versions, scopes and allow/deny/review decisions;
+- `src/SightAdapt/obj/project.assets.json`, including every package in `libraries` and every package/dependency edge in `targets`;
+- `tests/SightAdapt.Tests/obj/project.assets.json`, including direct and transitive test packages and their graph edges;
+- `DOTNET-NOTICE-METADATA.json` for exact packages and component hashes proven to be embedded in `SightAdapt.exe` or shipped as loose binaries;
+- `.github/workflows/build.yml` for maintained build actions;
+- `Directory.Build.props` for product, SDK, runtime and RID identity;
+- `release/dependency-policy.json` for license allow/deny/review decisions, version constraints and explicit custom-license overrides;
- the final publish directory for file names and SHA-256 checksums.
-A component not present in the policy is unresolved and fails the release step. A component version different from the reviewed version also fails. Denied or unreviewed license expressions fail before archive creation.
+The policy is an override and decision layer, not the component inventory. A new direct or transitive NuGet package is discovered from restore data even when it has no policy entry.
-## Generated files
+## NuGet license evidence
+
+For every discovered NuGet package the generator locates the exact restored package and records:
+
+- exact package identity and NuGet SHA-512;
+- SHA-256 of the exact `.nuspec` file;
+- declared license expression, license-file path or license URL from `.nuspec`;
+- SHA-256 of a packaged license file when one is declared;
+- package repository URL and commit where supplied;
+- package authors/supplier evidence.
+
+An SPDX expression present in exact package metadata is evaluated directly against the allow/deny/review lists. A policy override is required only for package-specific or custom terms that cannot be represented by the package's expression alone. Missing package cache data, missing `.nuspec`, unknown licenses, denied licenses and review-required licenses fail CI.
-The generator writes these files at the package root:
+Exact published Microsoft components continue to use the component-level evidence produced by #83. The runtime packs and `Microsoft.Windows.SDK.NET.Ref` retain exact package SHA-512, component SHA-256 and package-specific notice mappings.
+
+## Generated files
| File | Purpose |
|---|---|
-| `SBOM.spdx.json` | SPDX 2.3 document containing packages, scopes, suppliers, versions, declared/concluded licenses, source references, file inventory, relationships and checksums |
-| `LICENSE-REPORT.json` | License-policy result with allowed, denied and review lists plus the evaluated component inventory and failures |
-| `DEPENDENCIES.md` | Human-readable summary generated from the same evaluated component list |
+| `SBOM.spdx.json` | SPDX 2.3 document containing every package, complete dependency edges, scopes, versions, suppliers, licenses, purls, package checksums and packaged-file SHA-256 values |
+| `LICENSE-REPORT.json` | Schema-2 inventory and policy result containing direct/transitive classification, evidence hashes, application/test graph counts and failures |
+| `DEPENDENCIES.md` | Human-readable summary generated from the same authoritative component list |
+
+The SBOM excludes only its own final bytes from the file inventory. `SightAdapt.exe` is the documented single-file container for embedded components; separately shipped files remain individually hashed.
-The SBOM intentionally treats `SightAdapt.exe` as the single-file publication container. Runtime packs embedded by self-contained single-file publication are represented as package components and related to the SightAdapt package. Every separately shipped file is represented with its SHA-256 checksum. The SBOM excludes only itself from its file list because a file cannot contain a stable checksum of its own final bytes.
+## SPDX relationship rules
+
+The document describes the SightAdapt package. Relationships are intentionally scope-aware:
+
+- packages proven to be shipped are represented as `SightAdapt DEPENDS_ON `;
+- test-only packages are represented as ` TEST_DEPENDENCY_OF SightAdapt`;
+- SDK, Actions, restore-only and application-build packages are represented as ` BUILD_DEPENDENCY_OF SightAdapt`;
+- package-to-package dependency edges from each restore target use `DEPENDS_ON` and retain the originating application or test graph in the relationship comment;
+- SightAdapt `CONTAINS` every separately packaged file.
+
+Build and test tools are therefore not presented as runtime dependencies of the delivered application.
## License policy
-`release/dependency-policy.json` is the review source of truth. It defines:
+`release/dependency-policy.json` schema 2 defines:
-- allowed license expressions;
+- permissive expressions approved by maintainers;
- explicitly denied expressions;
-- expressions requiring review;
-- expected component versions or version sources;
-- supplier and source information;
-- whether a component is shipped, restore-only, test-only or build-only.
+- expressions requiring explicit review;
+- custom `LicenseRef` records included in the SPDX document;
+- version and supplier overrides for selected components;
+- package-specific conclusions where exact package metadata uses a file, URL or custom terms.
-Custom Microsoft `LicenseRef` expressions point to the exact Microsoft license, redistribution and third-party notice files included in the same package. They do not relicense Microsoft components under MIT.
+A package absent from `components` is allowed only when its exact metadata resolves to an allowed license. Absence from the policy never removes it from the inventory.
-## Release behavior
+## Negative test
-The maintained workflow runs SBOM generation after self-contained publication and exact-version .NET notice generation, but before ZIP creation. A failed component or license review prevents archive publication. `release/required-files.txt` requires the SBOM and report in every final distribution, so GitHub artifacts, manual ZIPs, installers, store packages, portable packages and mirrors must preserve them.
+`tools/test-sbom-license-negative.ps1` adds a deliberately transitive package to a copy of the real test restore graph. The fixture has valid package/nuspec checksums but no license metadata. CI must reject it specifically as an unresolved transitive license.
-The final ZIP retained for a release therefore contains the exact SBOM, license report and human-readable dependency inventory associated with the shipped bytes.
+## Release behavior
-## Maintenance
+The workflow restores both projects, retains both `project.assets.json` files in diagnostics, generates the real SBOM/license report, runs the unknown-transitive negative test and stops before archive creation on any failure. The final package contains the exact `SBOM.spdx.json`, `LICENSE-REPORT.json` and `DEPENDENCIES.md` associated with its bytes.
-Update the dependency policy when any package, runtime pack, SDK, GitHub Action, supplier, license or version changes. Do not add an unknown component with an assumed license. Review the authoritative package/repository terms, record the result in the policy and preserve any required notices.
+## Maintenance
-The SBOM is evidence of composition. It does not replace legal review of ambiguous, custom or conflicting terms.
+When dependencies change, do not add a handwritten inventory entry. Restore both projects and let the generator discover the graph. Update the policy only when a version constraint, supplier decision, custom license conclusion or allow/deny/review rule changes. Preserve additional notice text when a newly discovered package requires it.
From 032baca684ed3a8f8abe8c5222be229d0ca2822d Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:56:56 +0200
Subject: [PATCH 06/10] Align packaging with complete dependency graph
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
docs/PACKAGING.md | 41 ++++++++++++++++++++++++++++++-----------
1 file changed, 30 insertions(+), 11 deletions(-)
diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md
index 13829402..81425f56 100644
--- a/docs/PACKAGING.md
+++ b/docs/PACKAGING.md
@@ -15,12 +15,12 @@ The machine-readable source of truth is `release/required-files.txt`.
| `DOTNET-NOTICE-METADATA.json` | Exact package, component, notice and checksum evidence |
| `MICROSOFT-DOTNET-REDISTRIBUTION.txt` | Generated maintainer-reviewed redistribution notice |
| `THIRD-PARTY-NAMES-AND-DRM-NOTICE.txt` | Mark ownership, no-endorsement and DRM/access-control boundaries |
-| `DEPENDENCIES.md` | Human-readable dependency inventory |
-| `SBOM.spdx.json` | SPDX 2.3 component and shipped-file inventory |
-| `LICENSE-REPORT.json` | Dependency-license policy result |
+| `DEPENDENCIES.md` | Human-readable dependency inventory generated from complete restore graphs |
+| `SBOM.spdx.json` | SPDX 2.3 package graph and shipped-file inventory |
+| `LICENSE-REPORT.json` | Schema-2 dependency-license evidence and policy result |
| `PRIVACY.md` | Application privacy and local-data notice |
-A package is incomplete if a required file is missing, unreadable, stale, inconsistent with pinned inputs or contains a binary without exact component notice evidence.
+A package is incomplete if a required file is missing, unreadable, stale, inconsistent with pinned inputs, contains a binary without exact component notice evidence or has an unresolved dependency/license result.
## Publish sequence
@@ -28,7 +28,8 @@ A package is incomplete if a required file is missing, unreadable, stale, incons
2. `generate-dotnet-notices.ps1` imports exact official .NET legal text.
3. `generate-dotnet-component-coverage.ps1` maps all embedded and loose package components and adds package-specific notices.
4. `generate-dotnet-redistribution-notice.ps1` renders the reviewed redistribution summary.
-5. `generate-sbom.ps1` generates the dependency inventory, SPDX SBOM and license report.
+5. `generate-sbom.ps1` traverses complete application/test restore graphs, collects package license evidence and generates `DEPENDENCIES.md`, `LICENSE-REPORT.json` and `SBOM.spdx.json`.
+6. Negative tests prove rejection of incomplete, stale, unmapped and unknown-license inputs.
## Exact component coverage
@@ -50,6 +51,20 @@ The reviewed alpha contains 452 mapped package components across:
`Microsoft.Windows.SDK.NET.Ref` is classified as `shipped-embedded`, because `Microsoft.Windows.SDK.NET.dll` and `WinRT.Runtime.dll` are present in `FilesToBundle`.
+## Complete dependency and license inventory
+
+The SBOM generator uses both application and test `project.assets.json` files. Every NuGet package in `libraries`, every package dependency edge in `targets`, every framework download dependency, each package contributing published components, the pinned SDK and each maintained GitHub Action is inventoried.
+
+For NuGet packages the generated evidence retains exact package SHA-512, `.nuspec` SHA-256, declared license metadata, packaged-license SHA-256 where applicable, repository information and direct/transitive scope. `release/dependency-policy.json` supplies allow/deny/review decisions and selected custom-license overrides; it does not define which packages exist.
+
+SPDX root relationships distinguish scopes:
+
+- shipped packages: `SightAdapt DEPENDS_ON package`;
+- test-only packages: `package TEST_DEPENDENCY_OF SightAdapt`;
+- SDK, Actions, restore-only and application-build packages: `package BUILD_DEPENDENCY_OF SightAdapt`.
+
+Package-to-package restore edges remain `DEPENDS_ON` and identify the originating graph.
+
## Final package validation
```powershell
@@ -75,12 +90,16 @@ The general compliance gate validates package, metadata, license-report and SBOM
## Negative checks
```powershell
+.\tools\test-sbom-license-negative.ps1 `
+ -PublishDirectory '.\artifacts\win-x64'
+
.\tools\test-release-compliance-negative.ps1 `
-PublishDirectory '.\artifacts\win-x64'
```
The workflow proves rejection of:
+- a transitive package with valid package metadata but no declared/resolved license;
- an incomplete package;
- stale redistribution metadata;
- a package containing a real runtime DLL after its component mapping is deliberately removed.
@@ -92,16 +111,16 @@ The same legal/compliance bundle is required for Actions artifacts, manual ZIPs,
## Release checklist
1. verify canonical release metadata and maintainer decision;
-2. restore, build and test;
-3. publish and capture `FilesToBundle`;
+2. restore application and test projects;
+3. build, test, publish and capture `FilesToBundle`;
4. import exact official .NET notices;
5. generate exact package/component coverage;
6. generate the redistribution summary;
-7. generate SBOM, license report and dependency inventory;
-8. resolve every policy or component-mapping failure;
-9. prove incomplete, stale and unmapped packages are rejected;
+7. generate complete SBOM, license report and dependency inventory;
+8. resolve every unknown, denied, review-required or evidence failure;
+9. prove unknown-transitive, incomplete, stale and unmapped inputs are rejected;
10. create and run both validators against the final package;
11. retain the package and compliance report;
12. publish identical verified bytes to official mirrors.
-Do not publish when any notice, package hash, component mapping, SBOM, license, metadata or maintainer-decision check fails.
+Do not publish when any notice, package hash, component mapping, restore graph, SBOM, license, metadata or maintainer-decision check fails.
From 3be0b7e11d291a2638261895e1c5e8746304b34e Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 13:59:29 +0200
Subject: [PATCH 07/10] Validate SBOM license evidence and scopes
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
tools/verify-release-compliance.ps1 | 102 ++++++++++++++++++++++++++++
1 file changed, 102 insertions(+)
diff --git a/tools/verify-release-compliance.ps1 b/tools/verify-release-compliance.ps1
index 0fea6253..22081c29 100644
--- a/tools/verify-release-compliance.ps1
+++ b/tools/verify-release-compliance.ps1
@@ -171,8 +171,12 @@ if ([System.IO.File]::Exists($redistributionNoticePath)) {
(Get-FileHash -LiteralPath $redistributionNoticePath -Algorithm SHA256).Hash
}
+$licenseReport = $null
try {
$licenseReport = Get-Content -LiteralPath $licenseReportPath -Raw | ConvertFrom-Json
+ if ([int]$licenseReport.schemaVersion -ne 2) {
+ $failures.Add("LICENSE-REPORT.json uses schema '$($licenseReport.schemaVersion)' instead of schema 2.")
+ }
if ([string]$licenseReport.result -ne 'pass') {
$failures.Add("LICENSE-REPORT.json result is '$($licenseReport.result)', not 'pass'.")
}
@@ -182,11 +186,52 @@ try {
[string]$licenseReport.runtimeIdentifier -ne $rid) {
$failures.Add('License report metadata does not match release metadata.')
}
+
+ $reportComponents = @($licenseReport.components)
+ if ($reportComponents.Count -ne [int]$licenseReport.inventory.packageCount) {
+ $failures.Add('License report component count does not match its inventory summary.')
+ }
+ $calculatedTransitive = @($reportComponents | Where-Object {
+ [bool]$_.transitive -and [string]$_.name -ne 'SightAdapt'
+ }).Count
+ if ($calculatedTransitive -ne [int]$licenseReport.inventory.transitivePackageCount) {
+ $failures.Add('License report transitive-package count does not match its component inventory.')
+ }
+ if (@($licenseReport.dependencyEdges).Count -ne [int]$licenseReport.inventory.graphEdgeCount) {
+ $failures.Add('License report dependency-edge count does not match its inventory summary.')
+ }
+
+ foreach ($component in $reportComponents) {
+ $identity = "$($component.name)/$($component.version)"
+ if ([string]$component.status -ne 'approved') {
+ $failures.Add("License report component '$identity' is not approved.")
+ }
+ if ([string]$component.licenseConcluded -in @('', 'UNKNOWN', 'NOASSERTION')) {
+ $failures.Add("License report component '$identity' has no resolved concluded license.")
+ }
+ $evidenceType = [string]$component.evidence.evidenceType
+ if ([string]::IsNullOrWhiteSpace($evidenceType)) {
+ $failures.Add("License report component '$identity' has no evidence type.")
+ }
+ if ($evidenceType -eq 'nuget-package') {
+ if ([string]$component.evidence.packageSha512 -notmatch '^[0-9A-Fa-f]{128}$') {
+ $failures.Add("NuGet component '$identity' lacks package SHA-512 evidence.")
+ }
+ if ([string]$component.evidence.nuspecSha256 -notmatch '^[0-9A-Fa-f]{64}$') {
+ $failures.Add("NuGet component '$identity' lacks nuspec SHA-256 evidence.")
+ }
+ }
+ elseif ([string]$component.evidence.licenseEvidenceSha256 -notmatch '^[0-9A-Fa-f]{64}$' -and
+ [string]$component.name -ne 'Microsoft .NET SDK') {
+ $failures.Add("Component '$identity' lacks a policy or repository license-evidence hash.")
+ }
+ }
}
catch {
$failures.Add("LICENSE-REPORT.json cannot be validated: $($_.Exception.Message)")
}
+$sbom = $null
try {
$sbom = Get-Content -LiteralPath $sbomPath -Raw | ConvertFrom-Json
if ([string]$sbom.spdxVersion -ne 'SPDX-2.3') {
@@ -208,6 +253,59 @@ try {
if ($null -eq $sightAdaptPackage) {
$failures.Add('SBOM does not identify the SightAdapt single-file packaging container.')
}
+ elseif ($null -ne $licenseReport) {
+ $reportComponents = @($licenseReport.components)
+ if (@($sbom.packages).Count -ne $reportComponents.Count) {
+ $failures.Add('SBOM package count does not match LICENSE-REPORT.json.')
+ }
+ foreach ($component in $reportComponents) {
+ $package = @($sbom.packages | Where-Object {
+ [string]$_.name -eq [string]$component.name -and
+ [string]$_.versionInfo -eq [string]$component.version
+ }) | Select-Object -First 1
+ if ($null -eq $package) {
+ $failures.Add("SBOM does not contain component '$($component.name)/$($component.version)'.")
+ continue
+ }
+ if ([string]$package.licenseConcluded -ne [string]$component.licenseConcluded -or
+ [string]$package.licenseDeclared -ne [string]$component.licenseDeclared) {
+ $failures.Add("SBOM licenses for '$($component.name)/$($component.version)' do not match the license report.")
+ }
+ if ([string]$component.name -eq 'SightAdapt') {
+ continue
+ }
+
+ $runtimeRelationship = @($sbom.relationships | Where-Object {
+ [string]$_.spdxElementId -eq [string]$sightAdaptPackage.SPDXID -and
+ [string]$_.relationshipType -eq 'DEPENDS_ON' -and
+ [string]$_.relatedSpdxElement -eq [string]$package.SPDXID
+ }).Count -gt 0
+ $testRelationship = @($sbom.relationships | Where-Object {
+ [string]$_.spdxElementId -eq [string]$package.SPDXID -and
+ [string]$_.relationshipType -eq 'TEST_DEPENDENCY_OF' -and
+ [string]$_.relatedSpdxElement -eq [string]$sightAdaptPackage.SPDXID
+ }).Count -gt 0
+ $buildRelationship = @($sbom.relationships | Where-Object {
+ [string]$_.spdxElementId -eq [string]$package.SPDXID -and
+ [string]$_.relationshipType -eq 'BUILD_DEPENDENCY_OF' -and
+ [string]$_.relatedSpdxElement -eq [string]$sightAdaptPackage.SPDXID
+ }).Count -gt 0
+
+ if ([bool]$component.shipped) {
+ if (-not $runtimeRelationship) {
+ $failures.Add("Shipped component '$($component.identity)' lacks SightAdapt DEPENDS_ON relationship.")
+ }
+ }
+ elseif ([string]$component.scope -eq 'test') {
+ if (-not $testRelationship -or $runtimeRelationship) {
+ $failures.Add("Test component '$($component.identity)' is not represented exclusively as TEST_DEPENDENCY_OF SightAdapt.")
+ }
+ }
+ elseif (-not $buildRelationship -or $runtimeRelationship) {
+ $failures.Add("Build component '$($component.identity)' is not represented exclusively as BUILD_DEPENDENCY_OF SightAdapt.")
+ }
+ }
+ }
}
catch {
$failures.Add("SBOM.spdx.json cannot be validated: $($_.Exception.Message)")
@@ -228,6 +326,10 @@ $report = [ordered]@{
redistributionDecisionOwner = $redistributionDecisionOwner
redistributionDecisionIssue = $redistributionDecisionIssue
redistributionNoticeSha256 = $redistributionNoticeSha256
+ licenseReportSchemaVersion = if ($null -ne $licenseReport) { [int]$licenseReport.schemaVersion } else { $null }
+ licenseReportPackageCount = if ($null -ne $licenseReport) { @($licenseReport.components).Count } else { $null }
+ sbomPackageCount = if ($null -ne $sbom) { @($sbom.packages).Count } else { $null }
+ sbomRelationshipCount = if ($null -ne $sbom) { @($sbom.relationships).Count } else { $null }
artifactName = $artifactName
archiveFile = [System.IO.Path]::GetFileName($archivePathResolved)
archiveSha256 = (Get-FileHash -LiteralPath $archivePathResolved -Algorithm SHA256).Hash
From 3e9c7da03bab3013ba060816ec72bd026e9b37f5 Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:00:11 +0200
Subject: [PATCH 08/10] Document complete SBOM build flow
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
docs/BUILD.md | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/docs/BUILD.md b/docs/BUILD.md
index 66e17ef4..7b9b8cd4 100644
--- a/docs/BUILD.md
+++ b/docs/BUILD.md
@@ -31,6 +31,8 @@ dotnet test .\tests\SightAdapt.Tests\SightAdapt.Tests.csproj `
--no-restore
```
+Both `project.assets.json` files are required later. The SBOM generator reads all package entries and dependency edges from the application and test restore graphs, not only direct `PackageReference` elements.
+
## 3. Publish and capture bundle inputs
```powershell
@@ -82,14 +84,19 @@ The step also adds exact package notice sections for components not covered by t
-PublishDirectory .\artifacts\win-x64
```
+The generator writes `DEPENDENCIES.md`, schema-2 `LICENSE-REPORT.json` and SPDX 2.3 `SBOM.spdx.json` from the same inventory. For every NuGet package it retains exact package SHA-512 and `.nuspec` SHA-256 plus declared license metadata. Direct and transitive application/test packages are discovered automatically; the dependency policy supplies decisions and custom overrides rather than the inventory itself.
+
## 8. Run negative checks
```powershell
+.\tools\test-sbom-license-negative.ps1 `
+ -PublishDirectory .\artifacts\win-x64
+
.\tools\test-release-compliance-negative.ps1 `
-PublishDirectory .\artifacts\win-x64
```
-The validators must reject incomplete, stale and deliberately unmapped packages.
+The validators must reject a transitive package with no resolved license, an incomplete package, stale redistribution metadata and a package with a deliberately removed component mapping.
## 9. Create and verify the final ZIP
@@ -111,7 +118,7 @@ Compress-Archive `
-ArchivePath $archive
```
-Retain the verified archive and compliance report together.
+The compliance gate checks license evidence, package counts, graph edges and scope-aware SPDX relationships in addition to the existing package invariants. Retain the verified archive and compliance report together.
## Expected package root
@@ -140,4 +147,4 @@ Remove-Item .\artifacts\SightAdapt-*-win-x64.zip -Force -ErrorAction SilentlyCon
Remove-Item .\artifacts\SightAdapt-*-win-x64-compliance.json -Force -ErrorAction SilentlyContinue
```
-Do not publish when metadata review, notice import, component coverage, SBOM/license review, negative checks or final-package validation fails.
+Do not publish when metadata review, notice import, component coverage, complete dependency/license review, negative checks or final-package validation fails.
From 2c9970b13ec96428d4cca4803857c2845e75e7b3 Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:10:50 +0200
Subject: [PATCH 09/10] Fix SBOM generator parsing and optional graph
properties
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
tools/generate-sbom.ps1 | 31 +++++++++++++++++++++----------
1 file changed, 21 insertions(+), 10 deletions(-)
diff --git a/tools/generate-sbom.ps1 b/tools/generate-sbom.ps1
index d5753345..2d541941 100644
--- a/tools/generate-sbom.ps1
+++ b/tools/generate-sbom.ps1
@@ -114,7 +114,7 @@ function Split-PackageIdentity([string]$Identity) {
function Get-PolicyEntry([string]$Name) {
$property = @(
$policy.components.PSObject.Properties |
- Where-Object { ([string]$_.Name) -ieq $Name }
+ Where-Object { [string]$_.Name -ieq $Name }
) | Select-Object -First 1
if ($null -eq $property) {
return $null
@@ -228,13 +228,15 @@ function Read-RestoreGraph([string]$Path, [string]$GraphName) {
return
}
$directNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
- if ($null -ne $framework.Value.dependencies) {
- foreach ($dependency in @($framework.Value.dependencies.PSObject.Properties)) {
+ $frameworkDependencies = $framework.Value.PSObject.Properties['dependencies']
+ if ($null -ne $frameworkDependencies) {
+ foreach ($dependency in @($frameworkDependencies.Value.PSObject.Properties)) {
$directNames.Add([string]$dependency.Name) | Out-Null
}
}
- foreach ($download in @($framework.Value.downloadDependencies)) {
+ $downloadDependencies = $framework.Value.PSObject.Properties['downloadDependencies']
+ foreach ($download in @($(if ($null -ne $downloadDependencies) { $downloadDependencies.Value } else { @() }))) {
$name = [string]$download.name
$version = Resolve-ExactDownloadVersion ([string]$download.version)
if ([string]::IsNullOrWhiteSpace($version)) {
@@ -286,10 +288,11 @@ function Read-RestoreGraph([string]$Path, [string]$GraphName) {
}
$parentParts = Split-PackageIdentity ([string]$entry.Name)
$parentIdentity = Get-NormalizedIdentity $parentParts.Name $parentParts.Version
- if ($null -eq $entryValue.dependencies) {
+ $dependenciesProperty = $entryValue.PSObject.Properties['dependencies']
+ if ($null -eq $dependenciesProperty) {
continue
}
- foreach ($dependency in @($entryValue.dependencies.PSObject.Properties)) {
+ foreach ($dependency in @($dependenciesProperty.Value.PSObject.Properties)) {
$dependencyName = [string]$dependency.Name
$lookup = $dependencyName.ToLowerInvariant()
if ($targetIdentityByName.ContainsKey($lookup)) {
@@ -663,10 +666,18 @@ $summaryLines.Add('|---|---|---|---:|---:|---|---|---|')
foreach ($component in @($components | Sort-Object scope, name, version)) {
$evidenceLabel = [string]$component.evidence.evidenceType
if ([string]$component.evidence.nuspecSha256 -match '^[0-9A-Fa-f]{64}$') {
- $evidenceLabel += "; nuspec SHA-256 `$([string]$component.evidence.nuspecSha256)`"
- }
- $summaryLines.Add(
- "| $($component.name) | `$($component.version)` | $($component.scope) | $($component.direct) | $($component.shipped) | `$($component.licenseConcluded)` | $evidenceLabel | $($component.source) |")
+ $evidenceLabel += ('; nuspec SHA-256 `{0}`' -f [string]$component.evidence.nuspecSha256)
+ }
+ $summaryLines.Add((
+ '| {0} | `{1}` | {2} | {3} | {4} | `{5}` | {6} | {7} |' -f
+ $component.name,
+ $component.version,
+ $component.scope,
+ $component.direct,
+ $component.shipped,
+ $component.licenseConcluded,
+ $evidenceLabel,
+ $component.source))
}
$summaryLines.Add('')
$summaryLines.Add('`SBOM.spdx.json` contains the same package inventory, dependency graph and packaged-file hashes. `LICENSE-REPORT.json` contains the complete evidence and policy result. Build and test components are not represented as runtime dependencies of SightAdapt.')
From 9fab864c6695e209b34aed53efbfef4cdbd9b1e3 Mon Sep 17 00:00:00 2001
From: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
Date: Thu, 30 Jul 2026 14:14:23 +0200
Subject: [PATCH 10/10] Resolve exact restore-graph license decisions
Signed-off-by: KeyffMS <124252104+KeyffMS@users.noreply.github.com>
---
release/dependency-policy.json | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/release/dependency-policy.json b/release/dependency-policy.json
index 208da5ee..b6215fae 100644
--- a/release/dependency-policy.json
+++ b/release/dependency-policy.json
@@ -82,7 +82,7 @@
"scope": "restore-only",
"shipped": false,
"supplier": "Organization: Microsoft Corporation",
- "license": "LicenseRef-Microsoft-DotNet-Distribution",
+ "license": "MIT",
"source": "https://github.com/dotnet/aspnetcore",
"purpose": "LIBRARY",
"expectedVersion": ""
@@ -127,6 +127,16 @@
"purpose": "OTHER",
"expectedVersionSource": ""
},
+ "System.Reflection.Metadata": {
+ "expectedVersion": "1.6.0",
+ "expectedVersionSource": "",
+ "scope": "test-transitive",
+ "shipped": false,
+ "supplier": "Organization: Microsoft Corporation",
+ "license": "MIT",
+ "source": "https://www.nuget.org/packages/System.Reflection.Metadata/1.6.0",
+ "purpose": "OTHER"
+ },
"actions/checkout": {
"expectedVersion": "v4",
"scope": "build",