Skip to content
Open
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
290 changes: 274 additions & 16 deletions CycloneDX.Tests/NugetV3ServiceTests.cs

Large diffs are not rendered by default.

20 changes: 19 additions & 1 deletion CycloneDX/Models/RunOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@

namespace CycloneDX.Models
{
public enum EvidenceLicenseTextCollectionMode
{
/// <summary>
/// No evidence collection. This is the default.
/// </summary>
None,
/// <summary>
/// Collect evidence for all components which ship license file, regardless if license id or name is known or not.
/// </summary>
All,
/// <summary>
/// Collect license text only for components which have unknown license. This avoids collecting all license texts
/// for the case when license text can be obtained otherwise (like MIT) and therefore reduces the BOM size. In
/// contrast to the "All" mode, this mode will put license text into license block directly instead of evidence
/// part.
/// </summary>
Unknown,
}
public class RunOptions
{
public string SolutionOrProjectFile { get; set; }
Expand Down Expand Up @@ -50,6 +68,6 @@ public class RunOptions
public Component.Classification setType { get; set; } = Component.Classification.Application;
public bool setNugetPurl { get; set; }
public string DependencyExcludeFilter { get; set; }

public EvidenceLicenseTextCollectionMode evidenceCollectionMode { get; set; } = EvidenceLicenseTextCollectionMode.None;
}
}
9 changes: 6 additions & 3 deletions CycloneDX/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,14 @@ public static Task<int> Main(string[] args)
var setType = new Option<Component.Classification>(new[] { "--set-type", "-st" }, getDefaultValue: () => Component.Classification.Application, "Override the default BOM metadata component type (defaults to application).");
var setNugetPurl = new Option<bool>(new[] { "--set-nuget-purl" }, "Override the default BOM metadata component bom ref and PURL as NuGet package.");
var excludeFilter = new Option<string>(["--exclude-filter", "-ef"], "A comma separated list of dependencies to exclude in form 'name1@version1,name2@version2'. Transitive dependencies will also be removed.");
var evidenceCollectionMode = new Option<EvidenceLicenseTextCollectionMode>(new[] { "--collect-license-evidence", "-cle" }, "Collect license information from shipped files.");
//Deprecated args
var disableGithubLicenses = new Option<bool>(new[] { "--disable-github-licenses", "-dgl" }, "(Deprecated, this is the default setting now");
var outputFilenameDeprecated = new Option<string>(new[] { "-f" }, "(Deprecated use -fn instead) Optionally provide a filename for the BOM (default: bom.xml or bom.json).");
var excludeDevDeprecated = new Option<bool>(new[] {"-d" }, "(Deprecated use -ed instead) Exclude development dependencies from the BOM.");
var scanProjectDeprecated = new Option<bool>(new[] {"-r" }, "(Deprecated use -rs instead) To be used with a single project file, it will recursively scan project references of the supplied project file.");
var outputDirectoryDeprecated = new Option<string>(new[] { "--out", }, description: "(Deprecated use -output instead) The directory to write the BOM");


RootCommand rootCommand = new RootCommand
{
SolutionOrProjectFile,
Expand Down Expand Up @@ -101,7 +101,8 @@ public static Task<int> Main(string[] args)
scanProjectDeprecated,
outputDirectoryDeprecated,
disableGithubLicenses,
excludeFilter
excludeFilter,
evidenceCollectionMode
};
rootCommand.Description = "A .NET Core global tool which creates CycloneDX Software Bill-of-Materials (SBOM) from .NET projects.";
rootCommand.SetHandler(async (context) =>
Expand Down Expand Up @@ -136,7 +137,9 @@ public static Task<int> Main(string[] args)
setType = context.ParseResult.GetValueForOption(setType),
setNugetPurl = context.ParseResult.GetValueForOption(setNugetPurl),
includeProjectReferences = context.ParseResult.GetValueForOption(includeProjectReferences),
DependencyExcludeFilter = context.ParseResult.GetValueForOption(excludeFilter)
DependencyExcludeFilter = context.ParseResult.GetValueForOption(excludeFilter),
evidenceCollectionMode = context.ParseResult.GetValueForOption(evidenceCollectionMode)

};

Runner runner = new Runner();
Expand Down
116 changes: 114 additions & 2 deletions CycloneDX/Services/NugetV3Service.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,13 @@ public class NugetV3Service : INugetService
private readonly IFileSystem _fileSystem;
private readonly List<string> _packageCachePaths;
private readonly bool _disableHashComputation;
private readonly EvidenceLicenseTextCollectionMode _evidenceCollectionMode;

// Used in local files
private const string _nuspecExtension = ".nuspec";
private readonly List<string> _licenseFiles = new() {
"LICENSE.txt", "LICENCE.txt", "LICENCE.md", "LICENSE.md", "LICENSE", "LICENCE"
};
private const string _nupkgExtension = ".nupkg";
private const string _sha512Extension = ".nupkg.sha512";

Expand All @@ -62,13 +66,15 @@ public NugetV3Service(
List<string> packageCachePaths,
IGithubService githubService,
ILogger logger,
bool disableHashComputation
bool disableHashComputation,
EvidenceLicenseTextCollectionMode evidenceCollectionMode
)
{
_fileSystem = fileSystem;
_packageCachePaths = packageCachePaths;
_githubService = githubService;
_disableHashComputation = disableHashComputation;
_evidenceCollectionMode = evidenceCollectionMode;
_logger = logger;

_sourceRepository = SetupNugetRepository(nugetInput);
Expand Down Expand Up @@ -98,6 +104,42 @@ internal string GetCachedNuspecFilename(string name, string version)
return nuspecFilename;
}

internal string GetCachedLicenseFilename(string name, string version, string licenseFileNameHint)
{
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(version)) { return null; }

var lowerName = name.ToLowerInvariant();
var lowerVersion = version.ToLowerInvariant();
string licenseFilename = null;

foreach (var packageCachePath in _packageCachePaths)
{
var currentDirectory = _fileSystem.Path.Combine(packageCachePath, lowerName, NormalizeVersion(lowerVersion));
if (!string.IsNullOrEmpty(licenseFileNameHint))
{
var hintedLicenseFilename = _fileSystem.Path.Combine(currentDirectory, licenseFileNameHint);
//use provided in nuspec filename, if possible
if (_fileSystem.File.Exists(hintedLicenseFilename))
{
licenseFilename = hintedLicenseFilename;
break;
}
}
//otherwise probe for known license file names
foreach (var licenseFile in _licenseFiles)
{
var currentFilename = _fileSystem.Path.Combine(currentDirectory, licenseFile);
if (_fileSystem.File.Exists(currentFilename))
{
licenseFilename = currentFilename;
break;
}
}
}

return licenseFilename;
}

/// <summary>
/// Normalize the version string according to
/// https://learn.microsoft.com/en-us/nuget/concepts/package-versioning#normalized-version-numbers
Expand Down Expand Up @@ -269,11 +311,13 @@ public async Task<Component> GetComponentAsync(string name, string version, Comp
component.Licenses.Add(new LicenseChoice { License = license });
};
licenseMetadata.LicenseExpression.OnEachLeafNode(licenseProcessor, null);
await CollectLicenseEvidenceIfNeeded(null, name, version, licenseMetadata, component);
}
else if (_githubService == null)
{
var licenseUrl = nuspecModel.nuspecReader.GetLicenseUrl();
var license = new License { Name = "Unknown - See URL", Url = licenseUrl?.Trim() };
await CollectLicenseEvidenceIfNeeded(license, name, version, licenseMetadata, component);
component.Licenses = new List<LicenseChoice> { new LicenseChoice { License = license } };
}
else
Expand Down Expand Up @@ -309,7 +353,7 @@ public async Task<Component> GetComponentAsync(string name, string version, Comp
license = await _githubService.GetLicenseAsync(project).ConfigureAwait(false);
}
}

license = await CollectLicenseEvidenceIfNeeded(license, name, version, licenseMetadata, component);
if (license != null)
{
component.Licenses = new List<LicenseChoice> { new LicenseChoice { License = license } };
Expand Down Expand Up @@ -350,6 +394,74 @@ public async Task<Component> GetComponentAsync(string name, string version, Comp
return component;
}

private async Task<License> CollectLicenseEvidenceIfNeeded(License license, string name, string version, LicenseMetadata licenseMetadata, Component component)
{
if (_evidenceCollectionMode == EvidenceLicenseTextCollectionMode.None)
{
return license;
}
if (_evidenceCollectionMode == EvidenceLicenseTextCollectionMode.All)
{
var evidenceLicense = await CollectComponentLicenseText(name, version, null, licenseMetadata);
if (evidenceLicense != null)
{
component.Evidence ??= new Evidence();
component.Evidence.Licenses = new List<LicenseChoice> { new LicenseChoice { License = evidenceLicense } };
}
}
else if (_evidenceCollectionMode == EvidenceLicenseTextCollectionMode.Unknown &&
(license == null || string.IsNullOrEmpty(license.Id)))
{
license = await CollectComponentLicenseText(name, version, license, licenseMetadata);
}
return license;
}

private async Task<License> CollectComponentLicenseText(string name, string version, License license, LicenseMetadata licenseMetadata)
{
if (license != null && !string.IsNullOrEmpty(license.Id))
{
// must resolve to id or name, but not both
return license;
}
string licenseText = string.Empty;
var licenseFilename = GetCachedLicenseFilename(name, version,
//hint valid only of file-typed license
licenseMetadata?.Type == LicenseType.File ? licenseMetadata?.License : string.Empty);
if (!string.IsNullOrEmpty(licenseFilename))
{
try
{
licenseText = await _fileSystem.File.ReadAllTextAsync(licenseFilename, Encoding.UTF8);
}
catch (Exception e)
{
Console.Error.WriteLine($"Could not read License file.");
Console.WriteLine(e.Message);
}
}

if (!string.IsNullOrEmpty(licenseText))
{
if (license == null)
{
license = new License();
}
if (string.IsNullOrEmpty(license.Name))
{
license.Name = $"License detected in: {Path.GetFileName(licenseFilename)}";
}
license.Text = new AttachedText
{
Content = Convert.ToBase64String(Encoding.UTF8.GetBytes(licenseText)),
Encoding = "base64",
ContentType = "text/plain"
};
}

return license;
}

private static Component SetupComponentProperties(Component component, NuspecModel nuspecModel)
{
component.Authors = new List<OrganizationalContact> { new OrganizationalContact { Name = nuspecModel.nuspecReader.GetAuthors() } };
Expand Down
9 changes: 8 additions & 1 deletion CycloneDX/Services/NugetV3ServiceFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ public INugetService Create(RunOptions option, IFileSystem fileSystem, IGithubSe
{
var nugetLogger = new NuGet.Common.NullLogger();
var nugetInput = NugetInputFactory.Create(option.baseUrl, option.baseUrlUserName, option.baseUrlUSP, option.isPasswordClearText);
return new NugetV3Service(nugetInput, fileSystem, packageCachePaths, githubService, nugetLogger, option.disableHashComputation);
return new NugetV3Service(
nugetInput,
fileSystem,
packageCachePaths,
githubService,
nugetLogger,
option.disableHashComputation,
option.evidenceCollectionMode);
}
}
}
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Options:
-st, --set-type <Application|Container|Data|Device|Device_Driver| Override the default BOM metadata component type (defaults to application). [default: Application]
File|Firmware|Framework|Library|
Machine_Learning_Model|Null|Operating_System|Platform>
-cle, --collect-license-evidence <None|All|Unknown> [default: None] Collect license information from shipped files.
--set-nuget-purl Override the default BOM metadata component bom ref and PURL as NuGet package.
--version Show version information
-?, -h, --help Show help and usage information
Expand All @@ -105,6 +106,15 @@ Options:
which only supports .NET Standard 1.6. Without filter, the libraries of .NET Standard 1.6 would be in the
resulting SBOM. But they are not used by application as they do not exist in the binary output folder.

* `-cle, --collect-license-evidence`
The license evidence collection may be used to collect license information from shipped files, like LICENSE.txt.
This is particularly useful for packages, which have no license id provided, but rather information is provided in a file.
The default is `None` which means no license evidence will be collected. The other options are `All` which collects
all license evidence, even when the license id is known. Lastly, `Unknown` Collect license text only for components
which have unknown license. This avoids collecting all license texts for the case when license text can be obtained
otherwise (like MIT) and therefore reduces the BOM size. In contrast to the "All" mode, this mode will put license
text into license block directly instead of evidence part.

#### Examples
To run the **CycloneDX** tool you need to specify a solution or project file. In case you pass a solution, the tool will aggregate all the projects.

Expand Down