diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d9cd7d2..83c0cad 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -170,6 +170,17 @@ PackageGuard uses hierarchical JSON configuration: 3. Ensure existing tests still pass 4. Run `AcceptApiChanges` if public API changed +### Adding a New SBOM Format + +PackageGuard's `--sbom` feature computes a shared, format-agnostic model once (`PackageGuard.Core.Sbom.SbomModelBuilder` builds an `SbomModel` from purls, the dependency graph, license evidence, and OSV vulnerability data) and renders it once per format, so a new format only needs a renderer: + +1. Add a new `internal static class SbomWriter` in `Src/PackageGuard/`, with a single `Build(SbomModel model)` entry point, following the hand-rolled `[JsonPropertyName]` DTO style used by `CycloneDxSbomWriter.cs`/`SpdxSbomWriter.cs`/`RiskSarifReportWriter.cs` - do not add a third-party SBOM library dependency. +2. Wire the new format into `AnalyzeCommandSettings.Validate()` (accepted `--sbom` values) and `AnalyzeCommand.WriteSbom(...)`. +3. Add a corresponding `*SbomWriterSpecs.cs` in `Src/PackageGuard.Specs/Sbom/`, asserting structure via `JsonDocument.Parse` (no golden files), reusing the same `SbomModelBuilder.Build(...)` fixtures as the existing writer specs. +4. Update the SBOM section of `README.md`. + +If a new ecosystem is added to PackageGuard (beyond NuGet/npm), also extend `PackageUrlBuilder`'s ecosystem-to-purl-type mapping and `SbomModelBuilder.EcosystemsWithAccurateGraph` if that ecosystem builds a real dependency graph. + ## Pull Request Guidelines 1. **Target** the `develop` branch (not `main`) diff --git a/.packageguard/cache.bin b/.packageguard/cache.bin index c74ff24..abddccf 100644 Binary files a/.packageguard/cache.bin and b/.packageguard/cache.bin differ diff --git a/Build/Build.cs b/Build/Build.cs index e30982c..8a825e0 100644 --- a/Build/Build.cs +++ b/Build/Build.cs @@ -173,6 +173,24 @@ class Build : NukeBuild .SetProcessEnvironmentVariable(PackageGuardReportDirectoryEnvironmentVariable, reportDirectory) .AddApplicationArguments("--report-risk") .AddApplicationArguments($"--configpath={RootDirectory / ".packageguard" / "config.json"}")); + + AbsolutePath sbomFile = reportDirectory / "packageguard.cyclonedx.json"; + + Information("Running PackageGuard with SBOM generation in Cyclone DX format"); + DotNetRun(s => configurator(s) + .AddApplicationArguments("--sbom=cyclonedx") + .AddApplicationArguments($"--sbom-output={sbomFile}")); + + Assert.FileExists(sbomFile, $"Expected PackageGuard to generate an SBOM at {sbomFile}"); + + sbomFile = reportDirectory / "packageguard.spdx.json"; + + Information("Running PackageGuard with SBOM generation in SPDX format"); + DotNetRun(s => configurator(s) + .AddApplicationArguments("--sbom=spdx") + .AddApplicationArguments($"--sbom-output={sbomFile}")); + + Assert.FileExists(sbomFile, $"Expected PackageGuard to generate an SBOM at {sbomFile}"); }); Target CodeCoverage => _ => _ diff --git a/Build/_build.csproj b/Build/_build.csproj index 144703c..c6b3c1c 100644 --- a/Build/_build.csproj +++ b/Build/_build.csproj @@ -15,7 +15,7 @@ - + diff --git a/PackageGuard.sln.DotSettings b/PackageGuard.sln.DotSettings index d0df514..dca2acd 100644 --- a/PackageGuard.sln.DotSettings +++ b/PackageGuard.sln.DotSettings @@ -196,4 +196,5 @@ public void $Fact$() True True True - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/README.md b/README.md index 0c799da..9e5c89e 100644 --- a/README.md +++ b/README.md @@ -422,6 +422,29 @@ Example HTML report sections: - scoring rationale - collected evidence such as license, repository, release, maintainer, CI and dependency signals +### Software Bill of Materials (SBOM) + +PackageGuard can emit the resolved dependency graph as a standards-compliant SBOM, in either [CycloneDX](https://cyclonedx.org/) or [SPDX](https://spdx.dev/) JSON format, using the `--sbom` and `--sbom-output` flags: + +``` +packageguard --sbom cyclonedx --sbom-output bom.json +packageguard --sbom spdx --sbom-output bom.spdx.json +``` + +Both formats are built from the same resolved package data, so they always agree on what's included: + +- **Package URLs (purl)** for every component, e.g. `pkg:nuget/Newtonsoft.Json@13.0.3` or `pkg:npm/lodash@4.17.21`. +- **One aggregate SBOM per run**, covering every project in the analyzed solution, with a synthetic root component representing the solution itself. +- **Direct vs. transitive dependencies**, reflected as CycloneDX `scope`/`dependsOn` entries and SPDX `DEPENDS_ON` relationships. +- **License evidence** - a license declared by the package's own metadata (NuGet/npm registry data) is recorded differently from one PackageGuard concluded from external evidence, such as a GitHub repository scan or a heuristic match against downloaded license text. CycloneDX records this as a license `acknowledgement` of `declared` or `concluded`; SPDX records it by populating either `licenseDeclared` or `licenseConcluded` (the other is `NOASSERTION`). +- **Vulnerabilities** - combine `--sbom` with `--report-risk` to also populate a CycloneDX `vulnerabilities` section (or, for SPDX, a per-package annotation) from the same OSV data used for risk scoring. Without `--report-risk`, no vulnerability data is fetched or included. + +``` +packageguard --sbom cyclonedx --sbom-output bom.json --report-risk +``` + +**Known limitation:** PackageGuard currently only builds a real parent-child dependency graph for NuGet packages. npm, yarn, and pnpm packages are recorded as direct dependencies of the solution root rather than a fully nested tree, pending real dependency-graph parsing for those ecosystems. Both formats call this out explicitly - CycloneDX as a `metadata.properties` entry, SPDX as a document `comment` - so downstream consumers don't mistake a flat list for a complete graph. + ## Additional notes ### Speeding up the analysis using caching @@ -454,6 +477,7 @@ This is a rough list of items from my personal backlog that I'll be working on t - Expose the internal engine through the `PackageGuard.Core` NuGet package - Add direct support for [Nuke](https://nuke.build/) - Display the reason why a package was marked as a violation +- Build a real parent-child dependency graph for npm, yarn and pnpm projects, so `--sbom` output for those ecosystems is as accurate as it already is for NuGet ## Building diff --git a/Src/PackageGuard.ApiVerificationTests/ApprovedApi/net8.0.verified.txt b/Src/PackageGuard.ApiVerificationTests/ApprovedApi/net8.0.verified.txt index b44dd5c..aeff231 100644 --- a/Src/PackageGuard.ApiVerificationTests/ApprovedApi/net8.0.verified.txt +++ b/Src/PackageGuard.ApiVerificationTests/ApprovedApi/net8.0.verified.txt @@ -1,4 +1,5 @@ -[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("PackageGuard.Specs")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("PackageGuard")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("PackageGuard.Specs")] namespace PackageGuard.Core { public class AllowList : PackageGuard.Core.PackagePolicy @@ -35,6 +36,12 @@ namespace PackageGuard.Core public bool Prerelease { get; set; } } public delegate PackageGuard.Core.ProjectPolicy GetPolicyByProject(string projectPath); + public enum LicenseEvidence + { + Unknown = 0, + Declared = 1, + Concluded = 2, + } public sealed class LicenseFetcher { public LicenseFetcher(Microsoft.Extensions.Logging.ILogger logger, string? gitHubApiKey = null) { } @@ -48,6 +55,15 @@ namespace PackageGuard.Core Pnpm = 3, } [MemoryPack.MemoryPackable(MemoryPack.GenerateType.Object)] + public class OsvVulnerabilityRecord : MemoryPack.IMemoryPackFormatterRegister, MemoryPack.IMemoryPackable + { + public OsvVulnerabilityRecord() { } + public string[] Aliases { get; set; } + public string Id { get; set; } + public string[] References { get; set; } + public double Severity { get; set; } + } + [MemoryPack.MemoryPackable(MemoryPack.GenerateType.Object)] public class PackageInfo : MemoryPack.IMemoryPackFormatterRegister, MemoryPack.IMemoryPackable { public PackageInfo() { } @@ -110,6 +126,7 @@ namespace PackageGuard.Core public System.DateTimeOffset? LatestStablePublishedAt { get; set; } public string? LatestStableVersion { get; set; } public string? License { get; set; } + public PackageGuard.Core.LicenseEvidence LicenseEvidence { get; set; } public string? LicenseUrl { get; set; } public double? MajorReleaseRatio { get; set; } public double MaxVulnerabilitySeverity { get; set; } @@ -156,6 +173,7 @@ namespace PackageGuard.Core public double? VerifiedCommitRatio { get; set; } public string Version { get; set; } public double? VersionUpdateLagDays { get; set; } + public PackageGuard.Core.OsvVulnerabilityRecord[] Vulnerabilities { get; set; } public int VulnerabilityCount { get; set; } public double? WorkflowFailureRate { get; set; } public int? WorkflowPlatformCount { get; set; } diff --git a/Src/PackageGuard.Core/CSharp/FetchingStrategies/CorrectMisbehavingPackagesFetcher.cs b/Src/PackageGuard.Core/CSharp/FetchingStrategies/CorrectMisbehavingPackagesFetcher.cs index 04568b4..ff52ff4 100644 --- a/Src/PackageGuard.Core/CSharp/FetchingStrategies/CorrectMisbehavingPackagesFetcher.cs +++ b/Src/PackageGuard.Core/CSharp/FetchingStrategies/CorrectMisbehavingPackagesFetcher.cs @@ -21,11 +21,13 @@ public Task FetchLicenseAsync(PackageInfo package) } package.License ??= "MIT"; + package.LicenseEvidence = LicenseEvidence.Declared; } if (package.Name.Equals("NETStandard.Library", StringComparison.InvariantCultureIgnoreCase)) { package.License ??= "MIT"; + package.LicenseEvidence = LicenseEvidence.Declared; package.RepositoryUrl = "https://github.com/dotnet/standard"; } diff --git a/Src/PackageGuard.Core/CSharp/FetchingStrategies/GitHubLicenseFetcher.cs b/Src/PackageGuard.Core/CSharp/FetchingStrategies/GitHubLicenseFetcher.cs index 0eb7d92..ed3e379 100644 --- a/Src/PackageGuard.Core/CSharp/FetchingStrategies/GitHubLicenseFetcher.cs +++ b/Src/PackageGuard.Core/CSharp/FetchingStrategies/GitHubLicenseFetcher.cs @@ -42,6 +42,11 @@ public async Task FetchLicenseAsync(PackageInfo package) { package.License = null; } + + if (package.License is not null) + { + package.LicenseEvidence = LicenseEvidence.Concluded; + } } } } diff --git a/Src/PackageGuard.Core/CSharp/FetchingStrategies/UrlLicenseFetcher.cs b/Src/PackageGuard.Core/CSharp/FetchingStrategies/UrlLicenseFetcher.cs index 54df446..ca4dcef 100644 --- a/Src/PackageGuard.Core/CSharp/FetchingStrategies/UrlLicenseFetcher.cs +++ b/Src/PackageGuard.Core/CSharp/FetchingStrategies/UrlLicenseFetcher.cs @@ -22,18 +22,22 @@ public async Task FetchLicenseAsync(PackageInfo package) if (licenseText.Contains("MIT license", StringComparison.OrdinalIgnoreCase)) { package.License = "MIT"; + package.LicenseEvidence = LicenseEvidence.Concluded; } else if (licenseText.Contains("Apache License", StringComparison.OrdinalIgnoreCase)) { package.License = "Apache-2.0"; + package.LicenseEvidence = LicenseEvidence.Concluded; } else if (licenseText.Contains("GNU General Public License", StringComparison.OrdinalIgnoreCase)) { package.License = "GPL-3.0"; + package.LicenseEvidence = LicenseEvidence.Concluded; } else if (licenseText.Contains("MICROSOFT SOFTWARE LICENSE TERMS", StringComparison.OrdinalIgnoreCase)) { package.License = "Microsoft .NET Library License"; + package.LicenseEvidence = LicenseEvidence.Concluded; } else { diff --git a/Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs b/Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs index bc465b9..4fbd972 100644 --- a/Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs +++ b/Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs @@ -73,7 +73,12 @@ public async Task CollectPackageMetadata(string projectPath, string packageName, { package = packages.Add(package); - package.License ??= nuspecMetadata?.License; + // Assume that the license is declared in the nuspec metadata if it is not explicitly specified in the package. + if (package.License is null && nuspecMetadata?.License is not null) + { + package.License = nuspecMetadata.License; + package.LicenseEvidence = LicenseEvidence.Declared; + } if (package.License is null) { @@ -215,6 +220,9 @@ private void EnsureCredentialProvidersConfigured() Version = packageInfo.Identity.Version.ToNormalizedString(), RepositoryUrl = packageInfo.ProjectUrl?.ToString(), License = packageInfo.LicenseMetadata?.License, + LicenseEvidence = packageInfo.LicenseMetadata?.License is not null + ? LicenseEvidence.Declared + : LicenseEvidence.Unknown, LicenseUrl = packageInfo.LicenseUrl?.ToString(), IsDeprecated = LooksDeprecated(packageInfo), PublishedAt = packageInfo.Published, diff --git a/Src/PackageGuard.Core/InternalsVisibleTo.cs b/Src/PackageGuard.Core/InternalsVisibleTo.cs index 1317d75..29417dd 100644 --- a/Src/PackageGuard.Core/InternalsVisibleTo.cs +++ b/Src/PackageGuard.Core/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("PackageGuard.Specs")] +[assembly: InternalsVisibleTo("PackageGuard")] diff --git a/Src/PackageGuard.Core/LicenseEvidence.cs b/Src/PackageGuard.Core/LicenseEvidence.cs new file mode 100644 index 0000000..566e0a9 --- /dev/null +++ b/Src/PackageGuard.Core/LicenseEvidence.cs @@ -0,0 +1,24 @@ +namespace PackageGuard.Core; + +/// +/// Describes the provenance of a package's resolved . +/// +public enum LicenseEvidence +{ + /// + /// No license evidence has been recorded, or the license itself is unknown. + /// + Unknown, + + /// + /// The license was declared by the package's own metadata (NuGet API, .nuspec, npm registry/lock file), + /// or corrected to the actual publisher-declared license by a known-good override. + /// + Declared, + + /// + /// The license was concluded from external evidence rather than the package's own metadata, such as + /// a GitHub repository license scan or a heuristic match against downloaded license text. + /// + Concluded +} diff --git a/Src/PackageGuard.Core/Npm/NpmLockFileParser.cs b/Src/PackageGuard.Core/Npm/NpmLockFileParser.cs index b1b8784..223fbfd 100644 --- a/Src/PackageGuard.Core/Npm/NpmLockFileParser.cs +++ b/Src/PackageGuard.Core/Npm/NpmLockFileParser.cs @@ -87,6 +87,7 @@ public async Task CollectPackageMetadata(ChainablePath lockFilePath, PackageInfo Name = packageName, Version = packageEntry.Version, License = packageEntry.License, + LicenseEvidence = packageEntry.License is not null ? LicenseEvidence.Declared : LicenseEvidence.Unknown, Source = "npm", SourceUrl = packageEntry.Resolved ?? "https://registry.npmjs.org", DependencyDepth = Math.Max(1, packagePath.Split("node_modules/", StringSplitOptions.RemoveEmptyEntries).Length) diff --git a/Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs b/Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs index f8adafb..c3a1128 100644 --- a/Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs +++ b/Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs @@ -148,6 +148,11 @@ private void ParsePackageMetadata(PackageInfo package, JsonElement root) package.License = licenseElement.GetString(); } + if (package.License is not null) + { + package.LicenseEvidence = LicenseEvidence.Declared; + } + logger.LogDebug("Found license for {Name}: {License}", package.Name, package.License); } diff --git a/Src/PackageGuard.Core/OsvRiskEnricher.cs b/Src/PackageGuard.Core/OsvRiskEnricher.cs index 543b174..565d7ec 100644 --- a/Src/PackageGuard.Core/OsvRiskEnricher.cs +++ b/Src/PackageGuard.Core/OsvRiskEnricher.cs @@ -83,6 +83,7 @@ private async Task QueryAsync(PackageInfo package) bool hasPatchedRecent = false; bool hasAvailableFix = false; List fixDays = []; + List vulnerabilityRecords = []; do { @@ -120,6 +121,8 @@ private async Task QueryAsync(PackageInfo package) { fixDays.Add(daysToFix.Value); } + + vulnerabilityRecords.Add(ReadVulnerabilityRecord(vulnerability)); } } @@ -135,7 +138,39 @@ private async Task QueryAsync(PackageInfo package) MaxSeverity = maxSeverity, HasPatchedVulnerabilityInLast90Days = hasPatchedRecent, HasAvailableSecurityFix = hasAvailableFix, - MedianVulnerabilityFixDays = ComputeMedian(fixDays) + MedianVulnerabilityFixDays = ComputeMedian(fixDays), + Vulnerabilities = vulnerabilityRecords + }; + } + + /// + /// Extracts the identifier, aliases, severity, and reference URLs for a single OSV vulnerability entry. + /// + private static OsvVulnerabilityRecord ReadVulnerabilityRecord(JsonElement vulnerability) + { + string id = vulnerability.TryGetProperty("id", out JsonElement idElement) ? idElement.GetString() ?? "" : ""; + + string[] aliases = vulnerability.TryGetProperty("aliases", out JsonElement aliasesElement) && + aliasesElement.ValueKind == JsonValueKind.Array + ? aliasesElement.EnumerateArray().Select(a => a.GetString()).Where(a => a is not null).Select(a => a!).ToArray() + : []; + + string[] references = vulnerability.TryGetProperty("references", out JsonElement referencesElement) && + referencesElement.ValueKind == JsonValueKind.Array + ? referencesElement.EnumerateArray() + .Where(r => r.TryGetProperty("url", out _)) + .Select(r => r.GetProperty("url").GetString()) + .Where(url => url is not null) + .Select(url => url!) + .ToArray() + : []; + + return new OsvVulnerabilityRecord + { + Id = id, + Aliases = aliases, + Severity = ReadSeverity(vulnerability), + References = references }; } @@ -171,6 +206,7 @@ private static void Apply(PackageInfo package, OsvPackageRiskResult result) package.HasPatchedVulnerabilityInLast90Days = result.HasPatchedVulnerabilityInLast90Days; package.HasAvailableSecurityFix = result.HasAvailableSecurityFix; package.MedianVulnerabilityFixDays = result.MedianVulnerabilityFixDays; + package.Vulnerabilities = result.Vulnerabilities.ToArray(); package.HasOsvRiskData = true; } @@ -425,5 +461,10 @@ private sealed class OsvPackageRiskResult /// Median number of days from vulnerability publication to fix, or if no fix data is available. /// public double? MedianVulnerabilityFixDays { get; init; } + + /// + /// The individual vulnerability records found for the package. + /// + public IReadOnlyList Vulnerabilities { get; init; } = []; } } diff --git a/Src/PackageGuard.Core/OsvVulnerabilityRecord.cs b/Src/PackageGuard.Core/OsvVulnerabilityRecord.cs new file mode 100644 index 0000000..b07590f --- /dev/null +++ b/Src/PackageGuard.Core/OsvVulnerabilityRecord.cs @@ -0,0 +1,31 @@ +using MemoryPack; + +namespace PackageGuard.Core; + +/// +/// Represents a single vulnerability reported by the OSV API for a package version, retained so that +/// SBOM writers can render a vulnerabilities section rather than only aggregated counts. +/// +[MemoryPackable] +public partial class OsvVulnerabilityRecord +{ + /// + /// Gets or sets the OSV vulnerability identifier (e.g. GHSA-xxxx-xxxx-xxxx). + /// + public string Id { get; set; } = ""; + + /// + /// Gets or sets alternate identifiers for the same vulnerability (e.g. CVE-2024-12345). + /// + public string[] Aliases { get; set; } = []; + + /// + /// Gets or sets the highest severity score reported for this vulnerability. + /// + public double Severity { get; set; } + + /// + /// Gets or sets reference URLs (advisories, patches) associated with this vulnerability. + /// + public string[] References { get; set; } = []; +} diff --git a/Src/PackageGuard.Core/PackageInfo.cs b/Src/PackageGuard.Core/PackageInfo.cs index 24f2a37..e0d3b7b 100644 --- a/Src/PackageGuard.Core/PackageInfo.cs +++ b/Src/PackageGuard.Core/PackageInfo.cs @@ -31,6 +31,12 @@ public partial class PackageInfo /// public string? License { get; set; } + /// + /// Gets or sets the provenance of : whether it was declared by the package's own + /// metadata or concluded from external evidence such as a GitHub repository scan. + /// + public LicenseEvidence LicenseEvidence { get; set; } + /// /// Gets or sets the source URL where the license text or license metadata can be retrieved. /// @@ -138,6 +144,12 @@ public string[] Projects /// public bool HasPatchedVulnerabilityInLast90Days { get; set; } + /// + /// Gets or sets the individual OSV vulnerability records found for this package version. Only populated + /// when is enabled; empty otherwise. + /// + public OsvVulnerabilityRecord[] Vulnerabilities { get; set; } = []; + /// /// Gets or sets the shortest dependency depth discovered for this package in the analyzed graph. /// For NuGet, computes this with a breadth-first @@ -660,7 +672,7 @@ public void TrackAsUsedInProject(string projectPath) /// /// Determines the package ecosystem used when building dependency keys. /// - private string GetPackageEcosystem() + internal string GetPackageEcosystem() { if (Source.Equals("npm", StringComparison.OrdinalIgnoreCase) || SourceUrl.Contains("npmjs.org", StringComparison.OrdinalIgnoreCase) || diff --git a/Src/PackageGuard.Core/PackageInfoCollection.cs b/Src/PackageGuard.Core/PackageInfoCollection.cs index 633772c..866528c 100644 --- a/Src/PackageGuard.Core/PackageInfoCollection.cs +++ b/Src/PackageGuard.Core/PackageInfoCollection.cs @@ -55,7 +55,12 @@ private void UpdateLicenseForWellKnownLicenseUrls(PackageInfo package) { if (package.License is null && package.LicenseUrl is not null) { - package.License = cache.Values.FirstOrDefault(x => x.LicenseUrl == package.LicenseUrl)?.License; + PackageInfo? match = cache.Values.FirstOrDefault(x => x.LicenseUrl == package.LicenseUrl); + package.License = match?.License; + if (match?.License is not null) + { + package.LicenseEvidence = match.LicenseEvidence; + } } } @@ -278,7 +283,12 @@ private static void MergePackageMetadata(PackageInfo target, PackageInfo source) } target.RepositoryUrl ??= source.RepositoryUrl; - target.License ??= source.License; + if (target.License is null && source.License is not null) + { + target.License = source.License; + target.LicenseEvidence = source.LicenseEvidence; + } + target.LicenseUrl ??= source.LicenseUrl; } diff --git a/Src/PackageGuard.Core/Sbom/PackageUrlBuilder.cs b/Src/PackageGuard.Core/Sbom/PackageUrlBuilder.cs new file mode 100644 index 0000000..40ab770 --- /dev/null +++ b/Src/PackageGuard.Core/Sbom/PackageUrlBuilder.cs @@ -0,0 +1,48 @@ +namespace PackageGuard.Core.Sbom; + +/// +/// Builds Package URL (purl) identifiers for packages, following the +/// package-url/purl-spec. +/// +internal static class PackageUrlBuilder +{ + /// + /// Builds the purl for a package with the given ecosystem, name, and version. + /// + public static string Build(string ecosystem, string name, string version) + { + string type = ToPurlType(ecosystem); + string encodedVersion = Uri.EscapeDataString(version); + + return $"pkg:{type}/{BuildNamespaceAndName(type, name)}@{encodedVersion}"; + } + + /// + /// Maps a PackageGuard ecosystem identifier ("nuget"/"npm") to its purl type. + /// + private static string ToPurlType(string ecosystem) => ecosystem switch + { + "npm" => "npm", + _ => "nuget" + }; + + /// + /// Builds the purl namespace/name segment, percent-encoding npm scoped package names + /// (e.g. @scope/name becomes %40scope/name) per the purl spec. + /// + private static string BuildNamespaceAndName(string type, string name) + { + if (type == "npm" && name.StartsWith('@')) + { + int slashIndex = name.IndexOf('/'); + if (slashIndex > 0) + { + string scope = name[..slashIndex]; + string packageName = name[(slashIndex + 1)..]; + return $"{Uri.EscapeDataString(scope)}/{Uri.EscapeDataString(packageName)}"; + } + } + + return Uri.EscapeDataString(name); + } +} diff --git a/Src/PackageGuard.Core/Sbom/SbomComponent.cs b/Src/PackageGuard.Core/Sbom/SbomComponent.cs new file mode 100644 index 0000000..8b54b24 --- /dev/null +++ b/Src/PackageGuard.Core/Sbom/SbomComponent.cs @@ -0,0 +1,67 @@ +namespace PackageGuard.Core.Sbom; + +/// +/// A single component (package) to be rendered into an SBOM, derived from a . +/// Both the CycloneDX and SPDX writers consume this instead of directly, so +/// purl construction, license-evidence classification, and vulnerability shaping happen exactly once. +/// +internal sealed class SbomComponent +{ + /// + /// Gets the dependency-graph key (ecosystem|name|version) that identifies this component. + /// + public required string Key { get; init; } + + /// + /// Gets the Package URL (purl) that identifies this component. + /// + public required string Purl { get; init; } + + /// + /// Gets the package identifier as exposed by the package ecosystem. + /// + public required string Name { get; init; } + + /// + /// Gets the resolved package version. + /// + public required string Version { get; init; } + + /// + /// Gets the resolved license identifier or friendly license name, or when unknown. + /// + public string? License { get; init; } + + /// + /// Gets the provenance of : whether it was declared by the package's own metadata + /// or concluded from external evidence such as a GitHub repository scan. + /// + public LicenseEvidence LicenseEvidence { get; init; } + + /// + /// Gets the source URL where the license text or license metadata can be retrieved. + /// + public string? LicenseUrl { get; init; } + + /// + /// Gets the repository URL associated with the package, when known. + /// + public string? RepositoryUrl { get; init; } + + /// + /// Gets whether this component is a direct dependency of one of the analyzed projects, as opposed to + /// a transitive dependency. + /// + public bool IsDirect { get; init; } + + /// + /// Gets the project files that reference this component. + /// + public IReadOnlyList Projects { get; init; } = []; + + /// + /// Gets the OSV vulnerability records known for this component. Only populated when --report-risk + /// was passed in the same run; otherwise empty. + /// + public IReadOnlyList Vulnerabilities { get; init; } = []; +} diff --git a/Src/PackageGuard.Core/Sbom/SbomModel.cs b/Src/PackageGuard.Core/Sbom/SbomModel.cs new file mode 100644 index 0000000..6d4063d --- /dev/null +++ b/Src/PackageGuard.Core/Sbom/SbomModel.cs @@ -0,0 +1,52 @@ +namespace PackageGuard.Core.Sbom; + +/// +/// Describes the synthetic root component that represents the analyzed solution or project in an SBOM, +/// since PackageGuard aggregates across every project into a single document. +/// +/// The stable identifier used to reference this root from dependency/relationship entries. +/// The display name for the root component, derived from the resolved solution/project file. +internal sealed record SbomRootComponent(string BomRef, string Name); + +/// +/// A directed edge in the dependency graph, from a package to one of its direct dependencies. +/// Only present for ecosystems with an accurate parent-child graph (see ). +/// +/// The dependency key of the depending package. +/// The dependency key of the depended-upon package. +internal sealed record SbomGraphEdge(string FromKey, string ToKey); + +/// +/// The shared, format-agnostic SBOM data model computed once from resolved package metadata and rendered +/// by each format-specific writer (CycloneDX, SPDX). +/// +internal sealed class SbomModel +{ + /// + /// Gets the synthetic root component representing the analyzed solution. + /// + public required SbomRootComponent Root { get; init; } + + /// + /// Gets every component (package) discovered across all analyzed projects. + /// + public required IReadOnlyList Components { get; init; } + + /// + /// Gets the known parent-to-child dependency edges. Only populated for ecosystems whose + /// entry is . + /// + public required IReadOnlyList Edges { get; init; } + + /// + /// Gets, per ecosystem, whether a real parent-child dependency graph is available. Currently only + /// NuGet builds a real graph; npm/yarn/pnpm packages are recorded as direct/flat pending real + /// dependency-graph parsing for those ecosystems. + /// + public required IReadOnlyDictionary EcosystemGraphIsAccurate { get; init; } + + /// + /// Gets the timestamp at which this model was generated. + /// + public required DateTimeOffset GeneratedAt { get; init; } +} diff --git a/Src/PackageGuard.Core/Sbom/SbomModelBuilder.cs b/Src/PackageGuard.Core/Sbom/SbomModelBuilder.cs new file mode 100644 index 0000000..9280b15 --- /dev/null +++ b/Src/PackageGuard.Core/Sbom/SbomModelBuilder.cs @@ -0,0 +1,123 @@ +namespace PackageGuard.Core.Sbom; + +/// +/// Builds a shared from resolved package metadata, computing purls, the +/// dependency graph, and license/vulnerability shaping once so that every SBOM format writer renders +/// exactly the same data. +/// +internal static class SbomModelBuilder +{ + /// + /// Ecosystems for which a real parent-to-child dependency graph is currently available. Only NuGet + /// builds real dependency edges today; npm/yarn/pnpm lock-file parsers do not. + /// + private static readonly HashSet EcosystemsWithAccurateGraph = new(StringComparer.OrdinalIgnoreCase) { "nuget" }; + + /// + /// Builds the shared SBOM model from every package used across the analyzed solution. + /// + public static SbomModel Build(IReadOnlyCollection packages, string projectPath) + { + IReadOnlyDictionary packagesByKey = packages + .GroupBy(package => package.CreatePackageKey(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + return new SbomModel + { + Root = new SbomRootComponent("solution-root", ResolveRootName(projectPath)), + Components = packages.Select(BuildComponent).ToArray(), + Edges = BuildEdges(packages, packagesByKey), + EcosystemGraphIsAccurate = ComputeGraphAccuracy(packages), + GeneratedAt = DateTimeOffset.UtcNow + }; + } + + /// + /// Projects a single into its representation. + /// + private static SbomComponent BuildComponent(PackageInfo package) + { + string ecosystem = package.GetPackageEcosystem(); + + return new SbomComponent + { + Key = package.CreatePackageKey(), + Purl = PackageUrlBuilder.Build(ecosystem, package.Name, package.Version), + Name = package.Name, + Version = package.Version, + License = package.License, + LicenseEvidence = package.LicenseEvidence, + LicenseUrl = package.LicenseUrl, + RepositoryUrl = package.RepositoryUrl, + IsDirect = package.DependencyDepth <= 1, + Projects = package.Projects, + Vulnerabilities = package.Vulnerabilities + }; + } + + /// + /// Resolves each package's into graph edges, but only for + /// ecosystems with an accurate dependency graph (see ), so + /// the model never fabricates edges that don't reflect reality. + /// + private static IReadOnlyList BuildEdges(IReadOnlyCollection packages, + IReadOnlyDictionary packagesByKey) + { + List edges = []; + + foreach (PackageInfo package in packages) + { + if (!EcosystemsWithAccurateGraph.Contains(package.GetPackageEcosystem())) + { + continue; + } + + string fromKey = package.CreatePackageKey(); + foreach (string childKey in package.DependencyKeys.Where(packagesByKey.ContainsKey)) + { + edges.Add(new SbomGraphEdge(fromKey, childKey)); + } + } + + return edges; + } + + /// + /// Computes, per ecosystem present in , whether a real dependency graph + /// is available. + /// + private static IReadOnlyDictionary ComputeGraphAccuracy(IReadOnlyCollection packages) + { + return packages + .Select(package => package.GetPackageEcosystem()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToDictionary(ecosystem => ecosystem, ecosystem => EcosystemsWithAccurateGraph.Contains(ecosystem), + StringComparer.OrdinalIgnoreCase); + } + + /// + /// Resolves the display name for the synthetic root component from the best-matching solution, + /// project, or package manifest file found at . + /// + private static string ResolveRootName(string projectPath) + { + if (File.Exists(projectPath)) + { + return Path.GetFileNameWithoutExtension(projectPath); + } + + if (!Directory.Exists(projectPath)) + { + return Path.GetFileNameWithoutExtension(projectPath); + } + + string? candidate = Directory.EnumerateFiles(projectPath, "*.sln").FirstOrDefault() + ?? Directory.EnumerateFiles(projectPath, "*.slnx").FirstOrDefault() + ?? Directory.EnumerateFiles(projectPath, "*.csproj").FirstOrDefault() + ?? Directory.EnumerateFiles(projectPath, "package.json").FirstOrDefault(); + + return candidate is not null + ? Path.GetFileNameWithoutExtension(candidate) + : new DirectoryInfo(projectPath).Name; + } +} diff --git a/Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs b/Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs index 7ac79ec..95b51e8 100644 --- a/Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs +++ b/Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Spectre.Console; namespace PackageGuard.Specs; @@ -17,4 +18,46 @@ public void Bare_report_risk_flag_enables_risk_reporting_without_an_explicit_pat settings.GetReportRiskPath().Should().BeNull(); settings.ToCoreSettings().ReportRisk.Should().BeTrue(); } + + [TestMethod] + [DataRow("cyclonedx")] + [DataRow("CycloneDX")] + [DataRow("spdx")] + [DataRow("SPDX")] + public void Accepts_a_recognized_sbom_format_when_an_output_path_is_given(string format) + { + var settings = new AnalyzeCommandSettings { Sbom = format, SbomOutput = "bom.json" }; + + settings.Validate().Successful.Should().BeTrue(); + } + + [TestMethod] + public void Rejects_an_unrecognized_sbom_format() + { + var settings = new AnalyzeCommandSettings { Sbom = "bogus", SbomOutput = "bom.json" }; + + ValidationResult result = settings.Validate(); + + result.Successful.Should().BeFalse(); + result.Message.Should().Contain("--sbom"); + } + + [TestMethod] + public void Requires_an_output_path_when_sbom_is_specified() + { + var settings = new AnalyzeCommandSettings { Sbom = "cyclonedx" }; + + ValidationResult result = settings.Validate(); + + result.Successful.Should().BeFalse(); + result.Message.Should().Contain("--sbom-output"); + } + + [TestMethod] + public void Does_not_require_an_sbom_format_or_output_path_when_sbom_generation_is_not_requested() + { + var settings = new AnalyzeCommandSettings(); + + settings.Validate().Successful.Should().BeTrue(); + } } diff --git a/Src/PackageGuard.Specs/ParallelPackageRiskEnricherSpecs.cs b/Src/PackageGuard.Specs/ParallelPackageRiskEnricherSpecs.cs index 6015ad2..255b218 100644 --- a/Src/PackageGuard.Specs/ParallelPackageRiskEnricherSpecs.cs +++ b/Src/PackageGuard.Specs/ParallelPackageRiskEnricherSpecs.cs @@ -106,6 +106,9 @@ public async Task Osv_enricher_should_detect_vulnerabilities_and_fix_data_for_a_ package.HasOsvRiskData.Should().BeTrue(); package.VulnerabilityCount.Should().BeGreaterThan(0, "Newtonsoft.Json 12.0.3 has known CVEs in the OSV database"); package.HasAvailableSecurityFix.Should().BeTrue("a patched version exists for this vulnerability"); + package.Vulnerabilities.Should().HaveCount(package.VulnerabilityCount, + "every aggregated vulnerability count should have a matching individual record for SBOM reporting"); + package.Vulnerabilities.Should().OnlyContain(v => !string.IsNullOrWhiteSpace(v.Id)); } [TestMethod] diff --git a/Src/PackageGuard.Specs/Sbom/CycloneDxSbomWriterSpecs.cs b/Src/PackageGuard.Specs/Sbom/CycloneDxSbomWriterSpecs.cs new file mode 100644 index 0000000..7a26fb0 --- /dev/null +++ b/Src/PackageGuard.Specs/Sbom/CycloneDxSbomWriterSpecs.cs @@ -0,0 +1,162 @@ +using System.Linq; +using System.Text.Json; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageGuard.Core; +using PackageGuard.Core.Sbom; + +namespace PackageGuard.Specs.Sbom; + +[TestClass] +public class CycloneDxSbomWriterSpecs +{ + [TestMethod] + public void Writes_the_bom_header_the_root_component_and_every_package_as_a_component_with_a_purl() + { + var direct = new PackageInfo + { + Name = "Contoso.Direct", + Version = "1.0.0", + Source = "nuget", + License = "MIT", + LicenseEvidence = LicenseEvidence.Declared, + DependencyDepth = 1, + DependencyKeys = [PackageInfo.CreatePackageKey("Contoso.Transitive", "2.0.0")] + }; + + var transitive = new PackageInfo + { + Name = "Contoso.Transitive", + Version = "2.0.0", + Source = "nuget", + License = "Apache-2.0", + LicenseEvidence = LicenseEvidence.Concluded, + DependencyDepth = 2 + }; + + SbomModel model = SbomModelBuilder.Build([direct, transitive], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(CycloneDxSbomWriter.Build(model)); + JsonElement root = document.RootElement; + + root.GetProperty("bomFormat").GetString().Should().Be("CycloneDX"); + root.GetProperty("specVersion").GetString().Should().Be("1.6"); + root.GetProperty("metadata").GetProperty("component").GetProperty("name").GetString().Should().Be("Contoso"); + + JsonElement[] components = root.GetProperty("components").EnumerateArray().ToArray(); + components.Should().HaveCount(2); + + JsonElement directComponent = components.Single(c => c.GetProperty("name").GetString() == "Contoso.Direct"); + directComponent.GetProperty("purl").GetString().Should().Be("pkg:nuget/Contoso.Direct@1.0.0"); + directComponent.GetProperty("scope").GetString().Should().Be("required"); + directComponent.GetProperty("licenses")[0].GetProperty("license").GetProperty("id").GetString().Should().Be("MIT"); + directComponent.GetProperty("licenses")[0].GetProperty("license").GetProperty("acknowledgement").GetString() + .Should().Be("declared"); + + JsonElement transitiveComponent = components.Single(c => c.GetProperty("name").GetString() == "Contoso.Transitive"); + transitiveComponent.TryGetProperty("scope", out _).Should().BeFalse("transitive dependencies omit the scope field"); + transitiveComponent.GetProperty("licenses")[0].GetProperty("license").GetProperty("acknowledgement").GetString() + .Should().Be("concluded"); + } + + [TestMethod] + public void Attaches_direct_dependencies_to_the_root_and_records_known_transitive_edges() + { + var direct = new PackageInfo + { + Name = "Contoso.Direct", + Version = "1.0.0", + Source = "nuget", + DependencyDepth = 1, + DependencyKeys = [PackageInfo.CreatePackageKey("Contoso.Transitive", "2.0.0")] + }; + + var transitive = new PackageInfo + { + Name = "Contoso.Transitive", + Version = "2.0.0", + Source = "nuget", + DependencyDepth = 2 + }; + + SbomModel model = SbomModelBuilder.Build([direct, transitive], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(CycloneDxSbomWriter.Build(model)); + JsonElement[] dependencies = document.RootElement.GetProperty("dependencies").EnumerateArray().ToArray(); + + JsonElement rootEntry = dependencies.Single(d => d.GetProperty("ref").GetString() == "solution-root"); + rootEntry.GetProperty("dependsOn")[0].GetString().Should().Be("pkg:nuget/Contoso.Direct@1.0.0"); + + JsonElement directEntry = dependencies.Single(d => d.GetProperty("ref").GetString() == "pkg:nuget/Contoso.Direct@1.0.0"); + directEntry.GetProperty("dependsOn")[0].GetString().Should().Be("pkg:nuget/Contoso.Transitive@2.0.0"); + } + + [TestMethod] + public void Omits_the_vulnerabilities_section_when_no_package_carries_vulnerability_data() + { + var package = new PackageInfo { Name = "Contoso.Package", Version = "1.0.0", Source = "nuget" }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(CycloneDxSbomWriter.Build(model)); + + document.RootElement.TryGetProperty("vulnerabilities", out _).Should().BeFalse(); + } + + [TestMethod] + public void Includes_a_vulnerabilities_section_when_a_package_carries_osv_data() + { + var package = new PackageInfo + { + Name = "Contoso.Package", + Version = "1.0.0", + Source = "nuget", + Vulnerabilities = + [ + new OsvVulnerabilityRecord + { + Id = "GHSA-xxxx-xxxx-xxxx", + Severity = 7.5, + References = ["https://github.com/advisories/GHSA-xxxx-xxxx-xxxx"] + } + ] + }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(CycloneDxSbomWriter.Build(model)); + JsonElement vulnerability = document.RootElement.GetProperty("vulnerabilities")[0]; + + vulnerability.GetProperty("id").GetString().Should().Be("GHSA-xxxx-xxxx-xxxx"); + vulnerability.GetProperty("ratings")[0].GetProperty("score").GetDouble().Should().Be(7.5); + vulnerability.GetProperty("affects")[0].GetProperty("ref").GetString().Should().Be("pkg:nuget/Contoso.Package@1.0.0"); + } + + [TestMethod] + public void Describes_the_generating_tool_using_manufacturer_instead_of_the_removed_vendor_field() + { + var package = new PackageInfo { Name = "Contoso.Package", Version = "1.0.0", Source = "nuget" }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(CycloneDxSbomWriter.Build(model)); + JsonElement tool = document.RootElement.GetProperty("metadata").GetProperty("tools").GetProperty("components")[0]; + + tool.GetProperty("manufacturer").GetProperty("name").GetString().Should().Be("Dennis Doomen"); + tool.TryGetProperty("vendor", out _).Should().BeFalse( + "CycloneDX 1.6 removed the 'vendor' component field in favor of 'manufacturer'"); + } + + [TestMethod] + public void Notes_the_flat_dependency_graph_limitation_for_npm_family_ecosystems() + { + var package = new PackageInfo { Name = "left-pad", Version = "1.0.0", Source = "npm" }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(CycloneDxSbomWriter.Build(model)); + JsonElement[] properties = document.RootElement.GetProperty("metadata").GetProperty("properties").EnumerateArray().ToArray(); + + properties.Should().ContainSingle(p => p.GetProperty("name").GetString() == "packageguard:flat-dependency-graph"); + } +} diff --git a/Src/PackageGuard.Specs/Sbom/PackageUrlBuilderSpecs.cs b/Src/PackageGuard.Specs/Sbom/PackageUrlBuilderSpecs.cs new file mode 100644 index 0000000..05392d3 --- /dev/null +++ b/Src/PackageGuard.Specs/Sbom/PackageUrlBuilderSpecs.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageGuard.Core.Sbom; + +namespace PackageGuard.Specs.Sbom; + +[TestClass] +public class PackageUrlBuilderSpecs +{ + [TestMethod] + public void Builds_a_nuget_purl() + { + string purl = PackageUrlBuilder.Build("nuget", "Newtonsoft.Json", "13.0.3"); + + purl.Should().Be("pkg:nuget/Newtonsoft.Json@13.0.3"); + } + + [TestMethod] + public void Builds_an_npm_purl() + { + string purl = PackageUrlBuilder.Build("npm", "lodash", "4.17.21"); + + purl.Should().Be("pkg:npm/lodash@4.17.21"); + } + + [TestMethod] + public void Encodes_the_scope_of_a_scoped_npm_package() + { + string purl = PackageUrlBuilder.Build("npm", "@types/node", "20.1.0"); + + purl.Should().Be("pkg:npm/%40types/node@20.1.0"); + } + + [TestMethod] + public void Encodes_prerelease_version_identifiers() + { + string purl = PackageUrlBuilder.Build("nuget", "Contoso.Beta", "1.0.0-beta.1+build"); + + purl.Should().Be("pkg:nuget/Contoso.Beta@1.0.0-beta.1%2Bbuild"); + } +} diff --git a/Src/PackageGuard.Specs/Sbom/SbomEndToEndSpecs.cs b/Src/PackageGuard.Specs/Sbom/SbomEndToEndSpecs.cs new file mode 100644 index 0000000..78766f3 --- /dev/null +++ b/Src/PackageGuard.Specs/Sbom/SbomEndToEndSpecs.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageGuard.Core; +using PackageGuard.Core.Sbom; +using Pathy; + +namespace PackageGuard.Specs.Sbom; + +[TestClass] +public class SbomEndToEndSpecs +{ + private readonly LicenseFetcher licenseFetcher = + new(NullLogger.Instance, Environment.GetEnvironmentVariable("GITHUB_API_KEY")); + + [TestMethod] + public async Task Produces_a_parseable_cyclonedx_and_spdx_document_from_a_resolved_nuget_project_without_fetching_risk_data() + { + var analyzer = new ProjectAnalyzer(licenseFetcher); + var projectPath = ChainablePath.Current / "TestCases" / "SimpleApp" / "SimpleApp.csproj"; + + AnalysisResult result = await analyzer.ExecuteAnalysisWithRisk(projectPath, new AnalyzerSettings + { + ForceRestore = true + }, _ => new ProjectPolicy { AllowList = new AllowList { Licenses = ["mit"] } }); + + result.Packages.Should().NotBeEmpty(); + result.Packages.Should().OnlyContain(package => package.Vulnerabilities.Length == 0, + "SBOM generation without --report-risk must never populate vulnerability data"); + + SbomModel model = SbomModelBuilder.Build(result.Packages, projectPath); + + using JsonDocument cyclonedx = JsonDocument.Parse(CycloneDxSbomWriter.Build(model)); + cyclonedx.RootElement.GetProperty("bomFormat").GetString().Should().Be("CycloneDX"); + cyclonedx.RootElement.GetProperty("components").GetArrayLength().Should().Be(result.Packages.Length); + cyclonedx.RootElement.GetProperty("components").EnumerateArray() + .Should().OnlyContain(c => c.GetProperty("purl").GetString()!.StartsWith("pkg:nuget/")); + cyclonedx.RootElement.TryGetProperty("vulnerabilities", out _).Should().BeFalse(); + + using JsonDocument spdx = JsonDocument.Parse(SpdxSbomWriter.Build(model)); + spdx.RootElement.GetProperty("spdxVersion").GetString().Should().Be("SPDX-2.3"); + spdx.RootElement.GetProperty("packages").GetArrayLength().Should().Be(result.Packages.Length + 1, + "the synthetic root package plus one entry per resolved package"); + } + + [TestMethod] + public void Creates_missing_parent_directories_for_the_sbom_output_path() + { + string outputDirectory = Path.Combine(Path.GetTempPath(), "PackageGuard-SbomSpecs", Guid.NewGuid().ToString("N")); + string outputPath = Path.Combine(outputDirectory, "nested", "bom.json"); + Directory.Exists(outputDirectory).Should().BeFalse("the test must start from a directory that doesn't exist yet"); + + try + { + var settings = new AnalyzeCommandSettings { Sbom = "cyclonedx", SbomOutput = outputPath }; + var package = new PackageInfo { Name = "Contoso.Package", Version = "1.0.0", Source = "nuget" }; + + AnalyzeCommand.WriteSbom(settings, [package], NullLogger.Instance); + + File.Exists(outputPath).Should().BeTrue(); + } + finally + { + if (Directory.Exists(outputDirectory)) + { + Directory.Delete(outputDirectory, recursive: true); + } + } + } +} diff --git a/Src/PackageGuard.Specs/Sbom/SbomModelBuilderSpecs.cs b/Src/PackageGuard.Specs/Sbom/SbomModelBuilderSpecs.cs new file mode 100644 index 0000000..8cabcc9 --- /dev/null +++ b/Src/PackageGuard.Specs/Sbom/SbomModelBuilderSpecs.cs @@ -0,0 +1,107 @@ +using System.Linq; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageGuard.Core; +using PackageGuard.Core.Sbom; + +namespace PackageGuard.Specs.Sbom; + +[TestClass] +public class SbomModelBuilderSpecs +{ + [TestMethod] + public void Builds_a_purl_and_marks_direct_dependencies_for_nuget_packages() + { + var direct = new PackageInfo + { + Name = "Contoso.Direct", + Version = "1.0.0", + Source = "nuget", + SourceUrl = "https://api.nuget.org/v3/index.json", + DependencyDepth = 1, + DependencyKeys = [PackageInfo.CreatePackageKey("Contoso.Transitive", "2.0.0")] + }; + + var transitive = new PackageInfo + { + Name = "Contoso.Transitive", + Version = "2.0.0", + Source = "nuget", + SourceUrl = "https://api.nuget.org/v3/index.json", + DependencyDepth = 2 + }; + + SbomModel model = SbomModelBuilder.Build([direct, transitive], "Contoso.sln"); + + SbomComponent directComponent = model.Components.Single(c => c.Name == "Contoso.Direct"); + SbomComponent transitiveComponent = model.Components.Single(c => c.Name == "Contoso.Transitive"); + + directComponent.Purl.Should().Be("pkg:nuget/Contoso.Direct@1.0.0"); + directComponent.IsDirect.Should().BeTrue(); + transitiveComponent.IsDirect.Should().BeFalse(); + + model.Edges.Should().ContainSingle(edge => + edge.FromKey == directComponent.Key && edge.ToKey == transitiveComponent.Key); + } + + [TestMethod] + public void Marks_the_nuget_graph_as_accurate_and_the_npm_graph_as_flat() + { + var nugetPackage = new PackageInfo { Name = "A", Version = "1.0.0", Source = "nuget" }; + var npmPackage = new PackageInfo { Name = "b", Version = "1.0.0", Source = "npm" }; + + SbomModel model = SbomModelBuilder.Build([nugetPackage, npmPackage], "solution"); + + model.EcosystemGraphIsAccurate["nuget"].Should().BeTrue(); + model.EcosystemGraphIsAccurate["npm"].Should().BeFalse(); + } + + [TestMethod] + public void Does_not_synthesize_edges_for_ecosystems_without_an_accurate_graph() + { + var parent = new PackageInfo + { + Name = "parent", + Version = "1.0.0", + Source = "npm", + DependencyKeys = [PackageInfo.CreateDependencyKey("npm", "child", "1.0.0")] + }; + + var child = new PackageInfo { Name = "child", Version = "1.0.0", Source = "npm" }; + + SbomModel model = SbomModelBuilder.Build([parent, child], "solution"); + + model.Edges.Should().BeEmpty(); + } + + [TestMethod] + public void Carries_license_evidence_and_vulnerabilities_onto_the_component() + { + var package = new PackageInfo + { + Name = "Contoso.Package", + Version = "1.0.0", + Source = "nuget", + License = "MIT", + LicenseEvidence = LicenseEvidence.Concluded, + Vulnerabilities = + [ + new OsvVulnerabilityRecord { Id = "GHSA-xxxx", Severity = 7.5 } + ] + }; + + SbomModel model = SbomModelBuilder.Build([package], "solution"); + + SbomComponent component = model.Components.Single(); + component.LicenseEvidence.Should().Be(LicenseEvidence.Concluded); + component.Vulnerabilities.Should().ContainSingle().Which.Id.Should().Be("GHSA-xxxx"); + } + + [TestMethod] + public void Resolves_the_root_component_name_from_the_solution_file_name_without_its_extension() + { + SbomModel model = SbomModelBuilder.Build([], @"C:\repo\Contoso.sln"); + + model.Root.Name.Should().Be("Contoso"); + } +} diff --git a/Src/PackageGuard.Specs/Sbom/SpdxSbomWriterSpecs.cs b/Src/PackageGuard.Specs/Sbom/SpdxSbomWriterSpecs.cs new file mode 100644 index 0000000..f6b47db --- /dev/null +++ b/Src/PackageGuard.Specs/Sbom/SpdxSbomWriterSpecs.cs @@ -0,0 +1,154 @@ +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PackageGuard.Core; +using PackageGuard.Core.Sbom; + +namespace PackageGuard.Specs.Sbom; + +[TestClass] +public class SpdxSbomWriterSpecs +{ + /// + /// SPDX 2.3's required timestamp format: no fractional seconds and a literal 'Z' rather than a + /// '+00:00'-style offset. + /// + private static readonly Regex SpdxTimestampPattern = new(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$"); + + [TestMethod] + public void Writes_the_document_header_and_a_package_with_its_purl_as_an_external_ref() + { + var package = new PackageInfo + { + Name = "Contoso.Package", + Version = "1.0.0", + Source = "nuget", + License = "MIT", + LicenseEvidence = LicenseEvidence.Declared, + DependencyDepth = 1 + }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(SpdxSbomWriter.Build(model)); + JsonElement root = document.RootElement; + + root.GetProperty("spdxVersion").GetString().Should().Be("SPDX-2.3"); + root.GetProperty("name").GetString().Should().Be("Contoso"); + + JsonElement[] packages = root.GetProperty("packages").EnumerateArray().ToArray(); + packages.Should().HaveCount(2, "the synthetic root package plus the one resolved package"); + + JsonElement resolvedPackage = packages.Single(p => p.GetProperty("name").GetString() == "Contoso.Package"); + resolvedPackage.GetProperty("licenseDeclared").GetString().Should().Be("MIT"); + resolvedPackage.GetProperty("licenseConcluded").GetString().Should().Be("NOASSERTION"); + resolvedPackage.GetProperty("externalRefs")[0].GetProperty("referenceType").GetString().Should().Be("purl"); + resolvedPackage.GetProperty("externalRefs")[0].GetProperty("referenceLocator").GetString() + .Should().Be("pkg:nuget/Contoso.Package@1.0.0"); + } + + [TestMethod] + public void Records_a_concluded_license_separately_from_a_declared_one() + { + var package = new PackageInfo + { + Name = "Contoso.Package", + Version = "1.0.0", + Source = "nuget", + License = "Apache-2.0", + LicenseEvidence = LicenseEvidence.Concluded + }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(SpdxSbomWriter.Build(model)); + JsonElement resolvedPackage = document.RootElement.GetProperty("packages") + .EnumerateArray().Single(p => p.GetProperty("name").GetString() == "Contoso.Package"); + + resolvedPackage.GetProperty("licenseDeclared").GetString().Should().Be("NOASSERTION"); + resolvedPackage.GetProperty("licenseConcluded").GetString().Should().Be("Apache-2.0"); + } + + [TestMethod] + public void Describes_the_root_package_and_relates_direct_and_transitive_dependencies() + { + var direct = new PackageInfo + { + Name = "Contoso.Direct", + Version = "1.0.0", + Source = "nuget", + DependencyDepth = 1, + DependencyKeys = [PackageInfo.CreatePackageKey("Contoso.Transitive", "2.0.0")] + }; + + var transitive = new PackageInfo + { + Name = "Contoso.Transitive", + Version = "2.0.0", + Source = "nuget", + DependencyDepth = 2 + }; + + SbomModel model = SbomModelBuilder.Build([direct, transitive], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(SpdxSbomWriter.Build(model)); + JsonElement[] relationships = document.RootElement.GetProperty("relationships").EnumerateArray().ToArray(); + + relationships.Should().ContainSingle(r => + r.GetProperty("relationshipType").GetString() == "DESCRIBES" && + r.GetProperty("spdxElementId").GetString() == "SPDXRef-DOCUMENT"); + + relationships.Count(r => r.GetProperty("relationshipType").GetString() == "DEPENDS_ON").Should().Be(2, + "the root depends on the direct package, which in turn depends on the transitive package"); + } + + [TestMethod] + public void Summarizes_known_vulnerabilities_as_a_package_annotation() + { + var package = new PackageInfo + { + Name = "Contoso.Package", + Version = "1.0.0", + Source = "nuget", + Vulnerabilities = [new OsvVulnerabilityRecord { Id = "GHSA-xxxx-xxxx-xxxx", Severity = 9.1 }] + }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(SpdxSbomWriter.Build(model)); + JsonElement resolvedPackage = document.RootElement.GetProperty("packages") + .EnumerateArray().Single(p => p.GetProperty("name").GetString() == "Contoso.Package"); + + resolvedPackage.GetProperty("annotations")[0].GetProperty("comment").GetString().Should().Contain("GHSA-xxxx-xxxx-xxxx"); + resolvedPackage.GetProperty("annotations")[0].GetProperty("annotationDate").GetString() + .Should().MatchRegex(SpdxTimestampPattern.ToString()); + } + + [TestMethod] + public void Writes_timestamps_in_the_strict_spdx_format_without_fractional_seconds_or_an_offset() + { + var package = new PackageInfo { Name = "Contoso.Package", Version = "1.0.0", Source = "nuget" }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(SpdxSbomWriter.Build(model)); + string created = document.RootElement.GetProperty("creationInfo").GetProperty("created").GetString(); + + created.Should().MatchRegex(SpdxTimestampPattern.ToString(), + "SPDX 2.3 requires 'YYYY-MM-DDThh:mm:ssZ', not .NET's default round-trip format with fractional seconds and a '+00:00' offset"); + } + + [TestMethod] + public void Notes_the_flat_dependency_graph_limitation_for_npm_family_ecosystems_as_a_document_comment() + { + var package = new PackageInfo { Name = "left-pad", Version = "1.0.0", Source = "npm" }; + + SbomModel model = SbomModelBuilder.Build([package], "Contoso.sln"); + + using JsonDocument document = JsonDocument.Parse(SpdxSbomWriter.Build(model)); + + document.RootElement.GetProperty("comment").GetString().Should().Contain("npm"); + } +} diff --git a/Src/PackageGuard/AnalyzeCommand.cs b/Src/PackageGuard/AnalyzeCommand.cs index ad8fb9e..994496c 100644 --- a/Src/PackageGuard/AnalyzeCommand.cs +++ b/Src/PackageGuard/AnalyzeCommand.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using Microsoft.Extensions.Logging; using PackageGuard.Core; +using PackageGuard.Core.Sbom; using Spectre.Console; using Spectre.Console.Cli; @@ -58,6 +59,12 @@ protected override async Task ExecuteAsync(CommandContext context, AnalyzeC logger.LogHeader("Completing analysis"); + bool sbomRequested = !string.IsNullOrWhiteSpace(settings.Sbom); + if (sbomRequested && packages.Length > 0) + { + WriteSbom(settings, packages, logger); + } + if (settings.ReportRisk && packages.Length > 0) { await WriteRiskReportsAsync(logger, settings, packages); @@ -79,7 +86,12 @@ private static ProjectAnalyzer BuildAnalyzer(ILogger logger, AnalyzeCommandSetti AnalyzerSettings analyzerSettings, GetPolicyByProject getPolicy) { - if (settings.ReportRisk) + // SBOM generation also needs the full resolved package list, so route it through the same + // risk-capable analysis path as --report-risk. AnalyzerSettings.ReportRisk still only reflects + // the user's actual --report-risk flag, so requesting --sbom alone never triggers OSV/GitHub enrichment. + bool sbomRequested = !string.IsNullOrWhiteSpace(settings.Sbom); + + if (settings.ReportRisk || sbomRequested) { var result = await analyzer.ExecuteAnalysisWithRisk(settings.ProjectPath, analyzerSettings, getPolicy); return (result.Violations, result.Packages); @@ -147,6 +159,32 @@ private static int ReportViolations(ILogger logger, PolicyViolation[] violations return SuccessExitCode; } + /// + /// Builds the shared SBOM model from and writes it in the requested format + /// (CycloneDX or SPDX) to . + /// + internal static void WriteSbom(AnalyzeCommandSettings settings, PackageInfo[] packages, ILogger logger) + { + logger.LogHeader("Writing SBOM"); + + SbomModel model = SbomModelBuilder.Build(packages, settings.ProjectPath); + + string json = settings.Sbom!.Equals("spdx", StringComparison.OrdinalIgnoreCase) + ? SpdxSbomWriter.Build(model) + : CycloneDxSbomWriter.Build(model); + + string? outputDirectory = Path.GetDirectoryName(Path.GetFullPath(settings.SbomOutput!)); + if (!string.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + File.WriteAllText(settings.SbomOutput!, json); + + AnsiConsole.MarkupLine($"SBOM ({settings.Sbom!.ToLowerInvariant()}): [blue]{Markup.Escape(settings.SbomOutput!)}[/]"); + AnsiConsole.MarkupLine(""); + } + /// /// Maps a 0–100 risk score to an Ansi console color name for display. /// diff --git a/Src/PackageGuard/AnalyzeCommandSettings.cs b/Src/PackageGuard/AnalyzeCommandSettings.cs index 99e38ea..7d25b68 100644 --- a/Src/PackageGuard/AnalyzeCommandSettings.cs +++ b/Src/PackageGuard/AnalyzeCommandSettings.cs @@ -2,6 +2,7 @@ using JetBrains.Annotations; using PackageGuard.Core; using Pathy; +using Spectre.Console; using Spectre.Console.Cli; namespace PackageGuard; @@ -106,6 +107,39 @@ public class AnalyzeCommandSettings : CommandSettings [DefaultValue(false)] public bool Verbose { get; set; } + [Description( + "Generate a Software Bill of Materials for the resolved dependency graph, in the given format: cyclonedx or spdx. Requires --sbom-output.")] + [CommandOption("--sbom")] + public string? Sbom { get; set; } + + [Description("The output file path for the generated SBOM. Required when --sbom is specified.")] + [CommandOption("--sbom-output|--sbomoutput")] + public string? SbomOutput { get; set; } + + /// + /// Validates that , when specified, is a supported format and is paired with . + /// + public override ValidationResult Validate() + { + if (string.IsNullOrWhiteSpace(Sbom)) + { + return ValidationResult.Success(); + } + + if (!Sbom.Equals("cyclonedx", StringComparison.OrdinalIgnoreCase) && + !Sbom.Equals("spdx", StringComparison.OrdinalIgnoreCase)) + { + return ValidationResult.Error($"--sbom must be either \"cyclonedx\" or \"spdx\", but was \"{Sbom}\"."); + } + + if (string.IsNullOrWhiteSpace(SbomOutput)) + { + return ValidationResult.Error("--sbom-output must be specified when --sbom is used."); + } + + return ValidationResult.Success(); + } + /// /// Returns the report risk output path when overridden via the /// environment variable, or null if not set. diff --git a/Src/PackageGuard/CycloneDxSbomWriter.cs b/Src/PackageGuard/CycloneDxSbomWriter.cs new file mode 100644 index 0000000..e78c860 --- /dev/null +++ b/Src/PackageGuard/CycloneDxSbomWriter.cs @@ -0,0 +1,454 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using PackageGuard.Core; +using PackageGuard.Core.Sbom; + +namespace PackageGuard; + +/// +/// Builds a CycloneDX 1.6 JSON Software Bill of Materials document from a shared . +/// +internal static class CycloneDxSbomWriter +{ + /// + /// Builds the CycloneDX JSON document for . + /// + public static string Build(SbomModel model) + { + var bom = new CycloneDxBom + { + Metadata = BuildMetadata(model), + Components = model.Components.Select(BuildComponent).ToArray(), + Dependencies = BuildDependencies(model), + Vulnerabilities = BuildVulnerabilities(model) + }; + + return JsonSerializer.Serialize(bom, SerializerOptions); + } + + /// + /// Builds the document metadata, including the synthetic root component and, when one or more + /// ecosystems lack an accurate dependency graph, a caveat property explaining the limitation. + /// + private static CycloneDxMetadata BuildMetadata(SbomModel model) + { + var metadata = new CycloneDxMetadata + { + Timestamp = model.GeneratedAt, + Tools = new CycloneDxToolsChoice + { + Components = + [ + new CycloneDxToolComponent + { + Type = "application", + Name = "PackageGuard", + Manufacturer = new CycloneDxOrganizationalEntity { Name = "Dennis Doomen" } + } + ] + }, + Component = new CycloneDxComponent + { + Type = "application", + BomRef = model.Root.BomRef, + Name = model.Root.Name + } + }; + + string[] inaccurateEcosystems = model.EcosystemGraphIsAccurate + .Where(entry => !entry.Value) + .Select(entry => entry.Key) + .OrderBy(ecosystem => ecosystem, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (inaccurateEcosystems.Length > 0) + { + metadata.Properties = + [ + new CycloneDxProperty + { + Name = "packageguard:flat-dependency-graph", + Value = + $"The dependency graph for the following ecosystems is flat (direct dependencies only) " + + $"pending real parent-child parsing: {string.Join(", ", inaccurateEcosystems)}." + } + ]; + } + + return metadata; + } + + /// + /// Builds a single CycloneDX component entry, including its purl, scope, licenses, and project properties. + /// + private static CycloneDxComponent BuildComponent(SbomComponent component) + { + return new CycloneDxComponent + { + Type = "library", + BomRef = component.Purl, + Name = component.Name, + Version = component.Version, + Purl = component.Purl, + Scope = component.IsDirect ? "required" : null, + Licenses = BuildLicenses(component), + ExternalReferences = BuildExternalReferences(component), + Properties = BuildComponentProperties(component) + }; + } + + /// + /// Builds the licenses array for a component, recording whether the license was declared by the + /// package's own metadata or concluded from external evidence. + /// + private static CycloneDxLicenseEntry[]? BuildLicenses(SbomComponent component) + { + if (string.IsNullOrWhiteSpace(component.License) || + component.License.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return + [ + new CycloneDxLicenseEntry + { + License = new CycloneDxLicense + { + Id = component.License, + Acknowledgement = component.LicenseEvidence switch + { + LicenseEvidence.Concluded => "concluded", + LicenseEvidence.Declared => "declared", + _ => null + } + } + } + ]; + } + + /// + /// Builds the externalReferences array from the component's repository and license URLs. + /// + private static CycloneDxExternalReference[]? BuildExternalReferences(SbomComponent component) + { + List references = []; + + if (!string.IsNullOrWhiteSpace(component.RepositoryUrl)) + { + references.Add(new CycloneDxExternalReference { Type = "vcs", Url = component.RepositoryUrl }); + } + + if (!string.IsNullOrWhiteSpace(component.LicenseUrl)) + { + references.Add(new CycloneDxExternalReference { Type = "license", Url = component.LicenseUrl }); + } + + return references.Count > 0 ? references.ToArray() : null; + } + + /// + /// Builds one packageguard:project property per consuming project, for traceability. + /// + private static CycloneDxProperty[]? BuildComponentProperties(SbomComponent component) + { + if (component.Projects.Count == 0) + { + return null; + } + + return component.Projects + .Select(project => new CycloneDxProperty { Name = "packageguard:project", Value = project }) + .ToArray(); + } + + /// + /// Builds the dependencies graph: the root's direct dependencies, plus every known + /// parent-child edge for ecosystems with an accurate dependency graph. + /// + private static CycloneDxDependency[] BuildDependencies(SbomModel model) + { + List dependencies = []; + + string[] directRefs = model.Components + .Where(component => component.IsDirect) + .Select(component => component.Purl) + .ToArray(); + + dependencies.Add(new CycloneDxDependency { Ref = model.Root.BomRef, DependsOn = directRefs }); + + Dictionary purlByKey = model.Components.ToDictionary(c => c.Key, c => c.Purl, StringComparer.OrdinalIgnoreCase); + + foreach (var group in model.Edges.GroupBy(edge => edge.FromKey, StringComparer.OrdinalIgnoreCase)) + { + if (!purlByKey.TryGetValue(group.Key, out string? fromPurl)) + { + continue; + } + + string[] dependsOn = group + .Select(edge => purlByKey.GetValueOrDefault(edge.ToKey)) + .Where(purl => purl is not null) + .Select(purl => purl!) + .ToArray(); + + dependencies.Add(new CycloneDxDependency { Ref = fromPurl, DependsOn = dependsOn }); + } + + return dependencies.ToArray(); + } + + /// + /// Builds the vulnerabilities section from every component's OSV vulnerability records, or + /// when none carry any (i.e. --report-risk was not passed). + /// + private static CycloneDxVulnerability[]? BuildVulnerabilities(SbomModel model) + { + var vulnerabilities = model.Components + .SelectMany(component => component.Vulnerabilities.Select(vulnerability => (component, vulnerability))) + .GroupBy(entry => entry.vulnerability.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => new CycloneDxVulnerability + { + Id = group.Key, + Ratings = [new CycloneDxVulnerabilityRating { Score = group.Max(entry => entry.vulnerability.Severity), Method = "other" }], + Advisories = group.SelectMany(entry => entry.vulnerability.References) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(url => new CycloneDxAdvisory { Url = url }) + .ToArray(), + Affects = group.Select(entry => new CycloneDxAffects { Ref = entry.component.Purl }) + .DistinctBy(affects => affects.Ref, StringComparer.OrdinalIgnoreCase) + .ToArray() + }) + .ToArray(); + + return vulnerabilities.Length > 0 ? vulnerabilities : null; + } + + /// + /// Shared JSON serializer options: null properties are omitted and output is indented. + /// + private static readonly JsonSerializerOptions SerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + /// + /// Root object of a CycloneDX 1.6 JSON BOM document. + /// + private sealed class CycloneDxBom + { + [JsonPropertyName("$schema")] + public string Schema { get; init; } = "http://cyclonedx.org/schema/bom-1.6.schema.json"; + + [JsonPropertyName("bomFormat")] + public string BomFormat { get; init; } = "CycloneDX"; + + [JsonPropertyName("specVersion")] + public string SpecVersion { get; init; } = "1.6"; + + [JsonPropertyName("version")] + public int Version { get; init; } = 1; + + [JsonPropertyName("metadata")] + public CycloneDxMetadata Metadata { get; init; } = new(); + + [JsonPropertyName("components")] + public CycloneDxComponent[] Components { get; init; } = []; + + [JsonPropertyName("dependencies")] + public CycloneDxDependency[] Dependencies { get; init; } = []; + + [JsonPropertyName("vulnerabilities")] + public CycloneDxVulnerability[]? Vulnerabilities { get; init; } + } + + /// + /// Document-level metadata: generation timestamp, generating tool, and the synthetic root component. + /// + private sealed class CycloneDxMetadata + { + [JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; init; } + + [JsonPropertyName("tools")] + public CycloneDxToolsChoice Tools { get; init; } = new(); + + [JsonPropertyName("component")] + public CycloneDxComponent Component { get; init; } = new(); + + [JsonPropertyName("properties")] + public CycloneDxProperty[]? Properties { get; set; } + } + + /// + /// CycloneDX 1.6's tools choice wrapper, using the modern components array form. + /// + private sealed class CycloneDxToolsChoice + { + [JsonPropertyName("components")] + public CycloneDxToolComponent[] Components { get; init; } = []; + } + + /// + /// Describes the tool that generated this SBOM. + /// + private sealed class CycloneDxToolComponent + { + [JsonPropertyName("type")] + public string Type { get; init; } = "application"; + + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("manufacturer")] + public CycloneDxOrganizationalEntity? Manufacturer { get; init; } + } + + /// + /// A CycloneDX organizational entity, used here to name the manufacturer of the generating tool. + /// + private sealed class CycloneDxOrganizationalEntity + { + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + } + + /// + /// A single CycloneDX component: either a package or, for the root, the analyzed solution itself. + /// + private sealed class CycloneDxComponent + { + [JsonPropertyName("type")] + public string Type { get; init; } = "library"; + + [JsonPropertyName("bom-ref")] + public string BomRef { get; init; } = ""; + + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("version")] + public string? Version { get; init; } + + [JsonPropertyName("purl")] + public string? Purl { get; init; } + + [JsonPropertyName("scope")] + public string? Scope { get; init; } + + [JsonPropertyName("licenses")] + public CycloneDxLicenseEntry[]? Licenses { get; init; } + + [JsonPropertyName("externalReferences")] + public CycloneDxExternalReference[]? ExternalReferences { get; init; } + + [JsonPropertyName("properties")] + public CycloneDxProperty[]? Properties { get; init; } + } + + /// + /// Wraps a single license entry within a component's licenses array. + /// + private sealed class CycloneDxLicenseEntry + { + [JsonPropertyName("license")] + public CycloneDxLicense License { get; init; } = new(); + } + + /// + /// A CycloneDX license identifier, optionally annotated with whether it was declared or concluded. + /// + private sealed class CycloneDxLicense + { + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + [JsonPropertyName("acknowledgement")] + public string? Acknowledgement { get; init; } + } + + /// + /// An external reference (repository, license text) associated with a component. + /// + private sealed class CycloneDxExternalReference + { + [JsonPropertyName("type")] + public string Type { get; init; } = ""; + + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + } + + /// + /// A free-form name/value property attached to a component or the document metadata. + /// + private sealed class CycloneDxProperty + { + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("value")] + public string Value { get; init; } = ""; + } + + /// + /// A single entry in the CycloneDX dependencies graph: a component and its direct dependencies. + /// + private sealed class CycloneDxDependency + { + [JsonPropertyName("ref")] + public string Ref { get; init; } = ""; + + [JsonPropertyName("dependsOn")] + public string[] DependsOn { get; init; } = []; + } + + /// + /// A single vulnerability affecting one or more components, sourced from OSV data. + /// + private sealed class CycloneDxVulnerability + { + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + [JsonPropertyName("ratings")] + public CycloneDxVulnerabilityRating[] Ratings { get; init; } = []; + + [JsonPropertyName("advisories")] + public CycloneDxAdvisory[]? Advisories { get; init; } + + [JsonPropertyName("affects")] + public CycloneDxAffects[] Affects { get; init; } = []; + } + + /// + /// A severity rating for a vulnerability. + /// + private sealed class CycloneDxVulnerabilityRating + { + [JsonPropertyName("score")] + public double Score { get; init; } + + [JsonPropertyName("method")] + public string Method { get; init; } = "other"; + } + + /// + /// A reference URL for a vulnerability, such as a security advisory. + /// + private sealed class CycloneDxAdvisory + { + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + } + + /// + /// Identifies a component affected by a vulnerability. + /// + private sealed class CycloneDxAffects + { + [JsonPropertyName("ref")] + public string Ref { get; init; } = ""; + } +} diff --git a/Src/PackageGuard/SpdxSbomWriter.cs b/Src/PackageGuard/SpdxSbomWriter.cs new file mode 100644 index 0000000..333aa24 --- /dev/null +++ b/Src/PackageGuard/SpdxSbomWriter.cs @@ -0,0 +1,345 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using PackageGuard.Core; +using PackageGuard.Core.Sbom; + +namespace PackageGuard; + +/// +/// Builds an SPDX 2.3 JSON Software Bill of Materials document from a shared . +/// +internal static class SpdxSbomWriter +{ + /// + /// SPDX element ID for the synthetic root package that represents the analyzed solution. + /// + private const string RootPackageId = "SPDXRef-Package-root"; + + /// + /// Builds the SPDX JSON document for . + /// + public static string Build(SbomModel model) + { + IReadOnlyDictionary spdxIdsByKey = model.Components + .Select((component, index) => (component.Key, Id: $"SPDXRef-Package-{index}")) + .ToDictionary(entry => entry.Key, entry => entry.Id, StringComparer.OrdinalIgnoreCase); + + var document = new SpdxDocument + { + Name = model.Root.Name, + DocumentNamespace = $"https://packageguard/spdxdocs/{Uri.EscapeDataString(model.Root.Name)}-{Guid.NewGuid()}", + CreationInfo = new SpdxCreationInfo + { + Created = FormatSpdxTimestamp(model.GeneratedAt), + Creators = ["Tool: PackageGuard"] + }, + Packages = BuildPackages(model, spdxIdsByKey), + Relationships = BuildRelationships(model, spdxIdsByKey), + Comment = BuildComment(model) + }; + + return JsonSerializer.Serialize(document, SerializerOptions); + } + + /// + /// Builds the synthetic root package followed by one package entry per component. + /// + private static SpdxPackage[] BuildPackages(SbomModel model, IReadOnlyDictionary spdxIdsByKey) + { + var root = new SpdxPackage + { + SpdxId = RootPackageId, + Name = model.Root.Name, + DownloadLocation = "NOASSERTION", + FilesAnalyzed = false + }; + + SpdxPackage[] packages = model.Components + .Select(component => BuildPackage(component, spdxIdsByKey[component.Key])) + .ToArray(); + + return [root, .. packages]; + } + + /// + /// Builds a single SPDX package entry, mapping onto SPDX's distinct + /// licenseDeclared/licenseConcluded fields, and attaching an annotation summarizing any + /// known OSV vulnerabilities. + /// + private static SpdxPackage BuildPackage(SbomComponent component, string spdxId) + { + (string declared, string concluded) = ResolveLicenseFields(component); + + return new SpdxPackage + { + SpdxId = spdxId, + Name = component.Name, + VersionInfo = component.Version, + DownloadLocation = "NOASSERTION", + FilesAnalyzed = false, + LicenseDeclared = declared, + LicenseConcluded = concluded, + CopyrightText = "NOASSERTION", + ExternalRefs = + [ + new SpdxExternalRef + { + ReferenceCategory = "PACKAGE-MANAGER", + ReferenceType = "purl", + ReferenceLocator = component.Purl + } + ], + Annotations = BuildAnnotations(component) + }; + } + + /// + /// Maps a component's resolved license and its evidence onto SPDX's separate declared/concluded fields: + /// a license that came from the package's own metadata is recorded as declared; one inferred from + /// external evidence (e.g. a GitHub repository scan) is recorded as concluded instead. + /// + private static (string Declared, string Concluded) ResolveLicenseFields(SbomComponent component) + { + const string noAssertion = "NOASSERTION"; + + if (string.IsNullOrWhiteSpace(component.License) || + component.License.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) + { + return (noAssertion, noAssertion); + } + + return component.LicenseEvidence switch + { + LicenseEvidence.Concluded => (noAssertion, component.License), + _ => (component.License, noAssertion) + }; + } + + /// + /// Builds a single annotation summarizing the OSV vulnerabilities known for a component, or + /// when none were recorded (i.e. --report-risk was not passed). + /// + private static SpdxAnnotation[]? BuildAnnotations(SbomComponent component) + { + if (component.Vulnerabilities.Count == 0) + { + return null; + } + + string summary = string.Join(", ", + component.Vulnerabilities.Select(v => $"{v.Id} (severity {v.Severity:0.0})")); + + return + [ + new SpdxAnnotation + { + Annotator = "Tool: PackageGuard", + AnnotationDate = FormatSpdxTimestamp(DateTimeOffset.UtcNow), + AnnotationType = "OTHER", + Comment = $"OSV vulnerabilities: {summary}" + } + ]; + } + + /// + /// Builds the document's DESCRIBES relationship to the root package, the root's DEPENDS_ON + /// relationships to every direct component, and every known parent-child edge for ecosystems with an + /// accurate dependency graph. + /// + private static SpdxRelationship[] BuildRelationships(SbomModel model, IReadOnlyDictionary spdxIdsByKey) + { + List relationships = + [ + new() + { + SpdxElementId = "SPDXRef-DOCUMENT", + RelationshipType = "DESCRIBES", + RelatedSpdxElement = RootPackageId + } + ]; + + relationships.AddRange(model.Components + .Where(component => component.IsDirect) + .Select(component => new SpdxRelationship + { + SpdxElementId = RootPackageId, + RelationshipType = "DEPENDS_ON", + RelatedSpdxElement = spdxIdsByKey[component.Key] + })); + + relationships.AddRange(model.Edges + .Where(edge => spdxIdsByKey.ContainsKey(edge.FromKey) && spdxIdsByKey.ContainsKey(edge.ToKey)) + .Select(edge => new SpdxRelationship + { + SpdxElementId = spdxIdsByKey[edge.FromKey], + RelationshipType = "DEPENDS_ON", + RelatedSpdxElement = spdxIdsByKey[edge.ToKey] + })); + + return relationships.ToArray(); + } + + /// + /// Builds a document-level comment explaining that one or more ecosystems have a flat (direct-only) + /// dependency graph, or when every ecosystem's graph is accurate. + /// + private static string? BuildComment(SbomModel model) + { + string[] inaccurateEcosystems = model.EcosystemGraphIsAccurate + .Where(entry => !entry.Value) + .Select(entry => entry.Key) + .OrderBy(ecosystem => ecosystem, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return inaccurateEcosystems.Length > 0 + ? $"The dependency graph for the following ecosystems is flat (direct dependencies only) " + + $"pending real parent-child parsing: {string.Join(", ", inaccurateEcosystems)}." + : null; + } + + /// + /// Formats a timestamp per SPDX 2.3's required format: UTC, no fractional seconds, and a literal + /// Z designator rather than a +00:00 offset (e.g. 2026-08-17T19:12:46Z). + /// + private static string FormatSpdxTimestamp(DateTimeOffset value) => + value.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture); + + /// + /// Shared JSON serializer options: null properties are omitted and output is indented. + /// + private static readonly JsonSerializerOptions SerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + /// + /// Root object of an SPDX 2.3 JSON document. + /// + private sealed class SpdxDocument + { + [JsonPropertyName("spdxVersion")] + public string SpdxVersion { get; init; } = "SPDX-2.3"; + + [JsonPropertyName("dataLicense")] + public string DataLicense { get; init; } = "CC0-1.0"; + + [JsonPropertyName("SPDXID")] + public string SpdxId { get; init; } = "SPDXRef-DOCUMENT"; + + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("documentNamespace")] + public string DocumentNamespace { get; init; } = ""; + + [JsonPropertyName("creationInfo")] + public SpdxCreationInfo CreationInfo { get; init; } = new(); + + [JsonPropertyName("packages")] + public SpdxPackage[] Packages { get; init; } = []; + + [JsonPropertyName("relationships")] + public SpdxRelationship[] Relationships { get; init; } = []; + + [JsonPropertyName("comment")] + public string? Comment { get; init; } + } + + /// + /// Records when and by which tool the SPDX document was created. + /// + private sealed class SpdxCreationInfo + { + [JsonPropertyName("created")] + public string Created { get; init; } = ""; + + [JsonPropertyName("creators")] + public string[] Creators { get; init; } = []; + } + + /// + /// A single SPDX package: either the synthetic root or a resolved dependency. + /// + private sealed class SpdxPackage + { + [JsonPropertyName("SPDXID")] + public string SpdxId { get; init; } = ""; + + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("versionInfo")] + public string? VersionInfo { get; init; } + + [JsonPropertyName("downloadLocation")] + public string DownloadLocation { get; init; } = "NOASSERTION"; + + [JsonPropertyName("filesAnalyzed")] + public bool FilesAnalyzed { get; init; } + + [JsonPropertyName("licenseDeclared")] + public string? LicenseDeclared { get; init; } + + [JsonPropertyName("licenseConcluded")] + public string? LicenseConcluded { get; init; } + + [JsonPropertyName("copyrightText")] + public string? CopyrightText { get; init; } + + [JsonPropertyName("externalRefs")] + public SpdxExternalRef[]? ExternalRefs { get; init; } + + [JsonPropertyName("annotations")] + public SpdxAnnotation[]? Annotations { get; init; } + } + + /// + /// An external reference on a package, used here to carry the package's purl. + /// + private sealed class SpdxExternalRef + { + [JsonPropertyName("referenceCategory")] + public string ReferenceCategory { get; init; } = ""; + + [JsonPropertyName("referenceType")] + public string ReferenceType { get; init; } = ""; + + [JsonPropertyName("referenceLocator")] + public string ReferenceLocator { get; init; } = ""; + } + + /// + /// A freeform annotation attached to a package, used here to summarize known OSV vulnerabilities. + /// + private sealed class SpdxAnnotation + { + [JsonPropertyName("annotator")] + public string Annotator { get; init; } = ""; + + [JsonPropertyName("annotationDate")] + public string AnnotationDate { get; init; } = ""; + + [JsonPropertyName("annotationType")] + public string AnnotationType { get; init; } = "OTHER"; + + [JsonPropertyName("comment")] + public string Comment { get; init; } = ""; + } + + /// + /// A relationship between two SPDX elements, such as DESCRIBES or DEPENDS_ON. + /// + private sealed class SpdxRelationship + { + [JsonPropertyName("spdxElementId")] + public string SpdxElementId { get; init; } = ""; + + [JsonPropertyName("relationshipType")] + public string RelationshipType { get; init; } = ""; + + [JsonPropertyName("relatedSpdxElement")] + public string RelatedSpdxElement { get; init; } = ""; + } +}