Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Format>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`)
Expand Down
Binary file modified .packageguard/cache.bin
Binary file not shown.
18 changes: 18 additions & 0 deletions Build/Build.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => _ => _
Expand Down
2 changes: 1 addition & 1 deletion Build/_build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<PackageReference Include="Nuke.Common" Version="9.0.4" />
<PackageReference Include="Nuke.Components" Version="9.0.4" />
<PackageDownload Include="ReportGenerator" Version="[5.2.0]" />
<PackageDownload Include="GitVersion.Tool" Version="[6.0.2]" />
<PackageDownload Include="GitVersion.Tool" Version="[6.8.2]" />
<PackageDownload Include="JetBrains.ReSharper.GlobalTools" Version="[2025.3.3]" />
<PackageReference Include="Nuke.Utilities.IO.Compression" Version="10.1.0" />
<PackageReference Include="SharpCompress" Version="0.50.4" />
Expand Down
3 changes: 2 additions & 1 deletion PackageGuard.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,5 @@ public void $Fact$()
<s:Boolean x:Key="/Default/UserDictionary/Words/=enumerables/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=inequivalency/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=reflectify/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Reflectify/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Reflectify/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Sbom/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path-to-project>
packageguard --sbom spdx --sbom-output bom.spdx.json <path-to-project>
```

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 <path-to-project>
```

**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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) { }
Expand All @@ -48,6 +55,15 @@ namespace PackageGuard.Core
Pnpm = 3,
}
[MemoryPack.MemoryPackable(MemoryPack.GenerateType.Object)]
public class OsvVulnerabilityRecord : MemoryPack.IMemoryPackFormatterRegister, MemoryPack.IMemoryPackable<PackageGuard.Core.OsvVulnerabilityRecord>
{
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<PackageGuard.Core.PackageInfo>
{
public PackageInfo() { }
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public async Task FetchLicenseAsync(PackageInfo package)
{
package.License = null;
}

if (package.License is not null)
{
package.LicenseEvidence = LicenseEvidence.Concluded;
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
10 changes: 9 additions & 1 deletion Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions Src/PackageGuard.Core/InternalsVisibleTo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("PackageGuard.Specs")]
[assembly: InternalsVisibleTo("PackageGuard")]
24 changes: 24 additions & 0 deletions Src/PackageGuard.Core/LicenseEvidence.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace PackageGuard.Core;

/// <summary>
/// Describes the provenance of a package's resolved <see cref="PackageInfo.License"/>.
/// </summary>
public enum LicenseEvidence
{
/// <summary>
/// No license evidence has been recorded, or the license itself is unknown.
/// </summary>
Unknown,

/// <summary>
/// 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.
/// </summary>
Declared,

/// <summary>
/// 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.
/// </summary>
Concluded
}
1 change: 1 addition & 0 deletions Src/PackageGuard.Core/Npm/NpmLockFileParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
43 changes: 42 additions & 1 deletion Src/PackageGuard.Core/OsvRiskEnricher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ private async Task<OsvPackageRiskResult> QueryAsync(PackageInfo package)
bool hasPatchedRecent = false;
bool hasAvailableFix = false;
List<double> fixDays = [];
List<OsvVulnerabilityRecord> vulnerabilityRecords = [];

do
{
Expand Down Expand Up @@ -120,6 +121,8 @@ private async Task<OsvPackageRiskResult> QueryAsync(PackageInfo package)
{
fixDays.Add(daysToFix.Value);
}

vulnerabilityRecords.Add(ReadVulnerabilityRecord(vulnerability));
}
}

Expand All @@ -135,7 +138,39 @@ private async Task<OsvPackageRiskResult> QueryAsync(PackageInfo package)
MaxSeverity = maxSeverity,
HasPatchedVulnerabilityInLast90Days = hasPatchedRecent,
HasAvailableSecurityFix = hasAvailableFix,
MedianVulnerabilityFixDays = ComputeMedian(fixDays)
MedianVulnerabilityFixDays = ComputeMedian(fixDays),
Vulnerabilities = vulnerabilityRecords
};
}

/// <summary>
/// Extracts the identifier, aliases, severity, and reference URLs for a single OSV vulnerability entry.
/// </summary>
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
};
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -425,5 +461,10 @@ private sealed class OsvPackageRiskResult
/// Median number of days from vulnerability publication to fix, or <see langword="null"/> if no fix data is available.
/// </summary>
public double? MedianVulnerabilityFixDays { get; init; }

/// <summary>
/// The individual vulnerability records found for the package.
/// </summary>
public IReadOnlyList<OsvVulnerabilityRecord> Vulnerabilities { get; init; } = [];
}
}
31 changes: 31 additions & 0 deletions Src/PackageGuard.Core/OsvVulnerabilityRecord.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using MemoryPack;

namespace PackageGuard.Core;

/// <summary>
/// Represents a single vulnerability reported by the OSV API for a package version, retained so that
/// SBOM writers can render a <c>vulnerabilities</c> section rather than only aggregated counts.
/// </summary>
[MemoryPackable]
public partial class OsvVulnerabilityRecord
{
/// <summary>
/// Gets or sets the OSV vulnerability identifier (e.g. <c>GHSA-xxxx-xxxx-xxxx</c>).
/// </summary>
public string Id { get; set; } = "";

/// <summary>
/// Gets or sets alternate identifiers for the same vulnerability (e.g. <c>CVE-2024-12345</c>).
/// </summary>
public string[] Aliases { get; set; } = [];

/// <summary>
/// Gets or sets the highest severity score reported for this vulnerability.
/// </summary>
public double Severity { get; set; }

/// <summary>
/// Gets or sets reference URLs (advisories, patches) associated with this vulnerability.
/// </summary>
public string[] References { get; set; } = [];
}
Loading
Loading