diff --git a/CycloneDX.Tests/NugetV3ServiceTests.cs b/CycloneDX.Tests/NugetV3ServiceTests.cs index 2f6d3cde..0f74c8bb 100644 --- a/CycloneDX.Tests/NugetV3ServiceTests.cs +++ b/CycloneDX.Tests/NugetV3ServiceTests.cs @@ -48,7 +48,7 @@ public void GetCachedNuspecFilename_ReturnsFullPath() }; var mockGithubService = new Mock(); var nugetService = new NugetV3Service(null, mockFileSystem, cachePaths, mockGithubService.Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var nuspecFilename = nugetService.GetCachedNuspecFilename("TestPackage", "1.2.3"); @@ -73,7 +73,7 @@ public async Task GetComponent_FromCachedNuspecFile_ReturnsComponent() mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, new Mock().Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -115,7 +115,7 @@ public async Task GetComponent_FromCachedNuspecFile_UsesNormalizedVersions(strin mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, new Mock().Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", rawVersion, Component.ComponentScope.Required).ConfigureAwait(true); @@ -145,7 +145,7 @@ public async Task GetComponent_FromCachedNugetFile_ReturnsComponentWithHashUsing mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, new Mock().Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", $"{rawVersion}", Component.ComponentScope.Required).ConfigureAwait(true); @@ -181,7 +181,8 @@ public async Task GetComponent_FromCachedNugetFile_DoNotReturnsHash_WhenDisabled new List { XFS.Path(@"c:\nugetcache") }, new Mock().Object, new NullLogger(), - true); + true, + EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -251,7 +252,7 @@ public async Task GetComponent_FromCachedNuspecFile_UsesNormalizedVcsUrl(string mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, new Mock().Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -266,7 +267,7 @@ public async Task GetComponentFromNugetOrgReturnsComponent() new MockFileSystem(), new List { XFS.Path(@"c:\nugetcache") }, new Mock().Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var packageName = "Newtonsoft.Json"; var packageVersion = "13.0.1"; @@ -285,7 +286,7 @@ public async Task GetComponentFromNugetOrgReturnsComponent_disableHashComputatio new MockFileSystem(), new List { XFS.Path(@"c:\nugetcache") }, new Mock().Object, - new NullLogger(), true); + new NullLogger(), true, EvidenceLicenseTextCollectionMode.None); var packageName = "Newtonsoft.Json"; var packageVersion = "13.0.1"; @@ -319,7 +320,7 @@ public async Task GetComponent_GitHubLicenseLookup_ReturnsComponent() mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, mockGitHubService.Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -350,7 +351,7 @@ public async Task GetComponent_GitHubLicenseLookup_FromRepository_ReturnsCompone mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, mockGitHubService.Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -381,7 +382,7 @@ public async Task GetComponent_GitHubLicenseLookup_FromRepositoryAndCommit_Retur mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, mockGitHubService.Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -412,7 +413,7 @@ public async Task GetComponent_GitHubLicenseLookup_FromProjectUrl_ReturnsCompone mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, mockGitHubService.Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -444,7 +445,7 @@ public async Task GetComponent_GitHubLicenseLookup_FromRepository_WhenLicenseInv mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, mockGitHubService.Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -474,7 +475,7 @@ public async Task GetComponent_SingleLicenseExpression_ReturnsComponent() mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, mockGitHubService.Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -503,7 +504,7 @@ public async Task GetComponent_MultiLicenseExpression_ReturnsComponent() mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, mockGitHubService.Object, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -532,7 +533,7 @@ public async Task GetComponent_WhenGitHubServiceIsNull_UsesLicenseUrl() mockFileSystem, new List { XFS.Path(@"c:\nugetcache") }, null, - new NullLogger(), false); + new NullLogger(), false, EvidenceLicenseTextCollectionMode.None); var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); @@ -540,5 +541,262 @@ public async Task GetComponent_WhenGitHubServiceIsNull_UsesLicenseUrl() Assert.Equal("https://not-licence.url", component.Licenses.First().License.Url); Assert.Equal("Unknown - See URL", component.Licenses.First().License.Name); } + + [Theory] + [InlineData("LICENSE.txt", true)] + [InlineData("LICENCE.txt", true)] + [InlineData("LICENCE", true)] + [InlineData("LICENSE", true)] + [InlineData("LICENSE.md", true)] + [InlineData("LICENCE.md", true)] + [InlineData("LIC.md", true)] + [InlineData("LICENSE.txt.txt", true)] + [InlineData("LIC.md", false)] + public async Task GetComponent_CollectLicenseText_GetsTextAndSettingsCorrectly(string licenseFileName, bool isValidName) + { + var hintFileName = isValidName ? licenseFileName : "LICENSE.txt"; + var nuspecFileContents = $@" + + + testpackage + {hintFileName} + + "; + var licenseFileContents = @"this is license text"; + var mockFileSystem = new MockFileSystem(new Dictionary + { + { XFS.Path(@"c:\nugetcache\testpackage\1.0.0\testpackage.nuspec"), new MockFileData(nuspecFileContents) }, + { XFS.Path($@"c:\nugetcache\testpackage\1.0.0\{licenseFileName}"), new MockFileData(licenseFileContents) }, + }); + + var nugetService = new NugetV3Service(null, + mockFileSystem, + new List { XFS.Path(@"c:\nugetcache") }, + new Mock().Object, + new NullLogger(), false, + evidenceCollectionMode: EvidenceLicenseTextCollectionMode.Unknown); + + var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); + + if (isValidName) + { + Assert.NotNull(component.Licenses); + Assert.Single(component.Licenses); + Assert.NotNull(component.Licenses.First().License.Text); + //can be only base64 according to cyclonedx.org doc + Assert.Equal("base64", component.Licenses.First().License.Text.Encoding); + Assert.Equal("text/plain", component.Licenses.First().License.Text.ContentType); + var actualName = component.Licenses.First().License.Name; + Assert.True(actualName == $"License detected in: {licenseFileName}" || actualName == "Unknown - See URL"); + var actualText = Encoding.UTF8.GetString(Convert.FromBase64String(component.Licenses.First().License.Text.Content)); + Assert.Equal(licenseFileContents, actualText); + } else { + // file based license and invalid name? then no license expected + Assert.Null(component.Licenses); + } + } + + [Theory] + [InlineData(EvidenceLicenseTextCollectionMode.None)] + [InlineData(EvidenceLicenseTextCollectionMode.Unknown)] + [InlineData(EvidenceLicenseTextCollectionMode.All)] + + public async Task GetComponent_CollectLicenseText_LicenseNameFallsBackToUrl(EvidenceLicenseTextCollectionMode evidenceCollectionMode) + { + var nuspecFileContents = @" + + + testpackage + LICENSE.txt + + "; + var licenseFileContents = @"this is license text"; + var mockFileSystem = new MockFileSystem(new Dictionary + { + { XFS.Path(@"c:\nugetcache\testpackage\1.0.0\testpackage.nuspec"), new MockFileData(nuspecFileContents) }, + { XFS.Path($@"c:\nugetcache\testpackage\1.0.0\{"LICENSE.txt"}"), new MockFileData(licenseFileContents) }, + }); + + var nugetService = new NugetV3Service(null, + mockFileSystem, + new List { XFS.Path(@"c:\nugetcache") }, + null, + new NullLogger(), false, + evidenceCollectionMode); + + var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); + + switch (evidenceCollectionMode) + { + case EvidenceLicenseTextCollectionMode.None: + Assert.Null(component.Evidence); + Assert.Null(component.Licenses.First().License.Text); + Assert.Equal("Unknown - See URL", component.Licenses.First().License.Name); + break; + case EvidenceLicenseTextCollectionMode.Unknown: + Assert.NotNull(component.Licenses); + Assert.Single(component.Licenses); + Assert.NotNull(component.Licenses.First().License.Text); + Assert.Equal("base64", component.Licenses.First().License.Text.Encoding); + Assert.Equal("text/plain", component.Licenses.First().License.Text.ContentType); + Assert.Equal("Unknown - See URL", component.Licenses.First().License.Name); + var actualText = Encoding.UTF8.GetString(Convert.FromBase64String(component.Licenses.First().License.Text.Content)); + Assert.Equal(licenseFileContents, actualText); + break; + case EvidenceLicenseTextCollectionMode.All: + Assert.NotNull(component.Licenses); + Assert.Single(component.Licenses); + //id set, but not the text + Assert.Null(component.Licenses.First().License.Id); + Assert.NotNull(component.Licenses.First().License.Name); + Assert.Null(component.Licenses.First().License.Text); + // now check the evidence + Assert.NotNull(component.Evidence); + Assert.NotNull(component.Evidence.Licenses); + Assert.Single(component.Evidence.Licenses); + Assert.NotNull(component.Evidence.Licenses.First().License.Text); + Assert.Equal("License detected in: LICENSE.txt", component.Evidence.Licenses.First().License.Name); + Assert.Equal("base64", component.Evidence.Licenses.First().License.Text.Encoding); + Assert.Equal("text/plain", component.Evidence.Licenses.First().License.Text.ContentType); + actualText = Encoding.UTF8.GetString(Convert.FromBase64String(component.Evidence.Licenses.First().License.Text.Content)); + Assert.Equal(licenseFileContents, actualText); + break; + default: + Assert.Fail("Unexpected evidence collection mode"); + break; + } + } + + [Theory] + [InlineData(EvidenceLicenseTextCollectionMode.None)] + [InlineData(EvidenceLicenseTextCollectionMode.Unknown)] + [InlineData(EvidenceLicenseTextCollectionMode.All)] + public async Task GetComponent_CollectLicenseText_WhenLicenseIdCanBeResolved(EvidenceLicenseTextCollectionMode evidenceCollectionMode) + { + var nuspecFileContents = @" + + + testpackage + https://not-licence.url + + + "; + var licenseFileContents = @"this is license text"; + var mockFileSystem = new MockFileSystem(new Dictionary + { + { XFS.Path(@"c:\nugetcache\testpackage\1.0.0\testpackage.nuspec"), new MockFileData(nuspecFileContents) }, + { XFS.Path(@"c:\nugetcache\testpackage\1.0.0\LICENSE.txt"), new MockFileData(licenseFileContents) }, + }); + + var mockGitHubService = new Mock(); + mockGitHubService.Setup(x => x.GetLicenseAsync("https://licence.url")).Returns(Task.FromResult(new License { Id = "LicenseId" })); + + var nugetService = new NugetV3Service(null, + mockFileSystem, + new List { XFS.Path(@"c:\nugetcache") }, + mockGitHubService.Object, + new NullLogger(), false, + evidenceCollectionMode); + + var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); + + switch (evidenceCollectionMode) + { + case EvidenceLicenseTextCollectionMode.None: + Assert.Null(component.Evidence); + Assert.Null(component.Licenses.First().License.Text); + Assert.Null(component.Licenses.First().License.Name); + break; + case EvidenceLicenseTextCollectionMode.Unknown: + Assert.Null(component.Evidence); + Assert.Null(component.Licenses.First().License.Text); + Assert.Null(component.Licenses.First().License.Name); + break; + case EvidenceLicenseTextCollectionMode.All: + Assert.NotNull(component.Licenses); + Assert.Single(component.Licenses); + //id set, but not the text + Assert.NotNull(component.Licenses.First().License.Id); + Assert.Null(component.Licenses.First().License.Name); + Assert.Null(component.Licenses.First().License.Text); + // now check the evidence + Assert.NotNull(component.Evidence); + Assert.NotNull(component.Evidence.Licenses); + Assert.Single(component.Evidence.Licenses); + Assert.NotNull(component.Evidence.Licenses.First().License.Text); + Assert.Equal("License detected in: LICENSE.txt", component.Evidence.Licenses.First().License.Name); + Assert.Equal("base64", component.Evidence.Licenses.First().License.Text.Encoding); + Assert.Equal("text/plain", component.Evidence.Licenses.First().License.Text.ContentType); + var actualText = Encoding.UTF8.GetString(Convert.FromBase64String(component.Evidence.Licenses.First().License.Text.Content)); + Assert.Equal(licenseFileContents, actualText); + break; + default: + Assert.Fail("Unexpected evidence collection mode"); + break; + } + } + + [Theory] + [InlineData("Apache-2.0", EvidenceLicenseTextCollectionMode.None)] + [InlineData("Apache-2.0 OR MPL-2.0", EvidenceLicenseTextCollectionMode.None)] + [InlineData("Apache-2.0", EvidenceLicenseTextCollectionMode.Unknown)] + [InlineData("Apache-2.0 OR MPL-2.0", EvidenceLicenseTextCollectionMode.Unknown)] + [InlineData("Apache-2.0", EvidenceLicenseTextCollectionMode.All)] + [InlineData("Apache-2.0 OR MPL-2.0", EvidenceLicenseTextCollectionMode.All)] + public async Task GetComponent_CollectLicenseText_GetsTextWhenLicenseExpressionSet(string expression, EvidenceLicenseTextCollectionMode evidenceCollectionMode) + { + ///"License detected in" + var nuspecFileContents = $@" + + + testpackage + {expression} + + "; + var licenseFileContents = @"this is license text"; + var mockFileSystem = new MockFileSystem(new Dictionary + { + { XFS.Path(@"c:\nugetcache\testpackage\1.0.0\testpackage.nuspec"), new MockFileData(nuspecFileContents) }, + { XFS.Path(@"c:\nugetcache\testpackage\1.0.0\LICENSE.txt"), new MockFileData(licenseFileContents) }, + }); + + var mockGitHubService = new Mock(); + + var nugetService = new NugetV3Service(null, + mockFileSystem, + new List { XFS.Path(@"c:\nugetcache") }, + mockGitHubService.Object, + new NullLogger(), false, + evidenceCollectionMode); + + var component = await nugetService.GetComponentAsync("testpackage", "1.0.0", Component.ComponentScope.Required).ConfigureAwait(true); + + switch (evidenceCollectionMode) + { + case EvidenceLicenseTextCollectionMode.None: + Assert.Null(component.Evidence); + Assert.Null(component.Licenses.First().License.Text); + Assert.Null(component.Licenses.First().License.Name); + break; + case EvidenceLicenseTextCollectionMode.Unknown: + Assert.Null(component.Evidence); + Assert.Null(component.Licenses.First().License.Text); + Assert.Null(component.Licenses.First().License.Name); + break; + case EvidenceLicenseTextCollectionMode.All: + Assert.NotNull(component.Evidence.Licenses); + Assert.Single(component.Evidence.Licenses); + Assert.NotNull(component.Evidence.Licenses.First().License.Text); + Assert.Equal("License detected in: LICENSE.txt", component.Evidence.Licenses.First().License.Name); + Assert.Equal("base64", component.Evidence.Licenses.First().License.Text.Encoding); + Assert.Equal("text/plain", component.Evidence.Licenses.First().License.Text.ContentType); + var actualText = Encoding.UTF8.GetString(Convert.FromBase64String(component.Evidence.Licenses.First().License.Text.Content)); + Assert.Equal(licenseFileContents, actualText); + break; + default: + Assert.Fail("Unexpected evidence collection mode"); + break; + } + } } } diff --git a/CycloneDX/Models/RunOptions.cs b/CycloneDX/Models/RunOptions.cs index 3144ff21..f39c0a31 100644 --- a/CycloneDX/Models/RunOptions.cs +++ b/CycloneDX/Models/RunOptions.cs @@ -19,6 +19,24 @@ namespace CycloneDX.Models { + public enum EvidenceLicenseTextCollectionMode + { + /// + /// No evidence collection. This is the default. + /// + None, + /// + /// Collect evidence for all components which ship license file, regardless if license id or name is known or not. + /// + All, + /// + /// 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. + /// + Unknown, + } public class RunOptions { public string SolutionOrProjectFile { get; set; } @@ -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; } } diff --git a/CycloneDX/Program.cs b/CycloneDX/Program.cs index 32d2348f..8da0d138 100644 --- a/CycloneDX/Program.cs +++ b/CycloneDX/Program.cs @@ -58,6 +58,7 @@ public static Task Main(string[] args) var setType = new Option(new[] { "--set-type", "-st" }, getDefaultValue: () => Component.Classification.Application, "Override the default BOM metadata component type (defaults to application)."); var setNugetPurl = new Option(new[] { "--set-nuget-purl" }, "Override the default BOM metadata component bom ref and PURL as NuGet package."); var excludeFilter = new Option(["--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(new[] { "--collect-license-evidence", "-cle" }, "Collect license information from shipped files."); //Deprecated args var disableGithubLicenses = new Option(new[] { "--disable-github-licenses", "-dgl" }, "(Deprecated, this is the default setting now"); var outputFilenameDeprecated = new Option(new[] { "-f" }, "(Deprecated use -fn instead) Optionally provide a filename for the BOM (default: bom.xml or bom.json)."); @@ -65,7 +66,6 @@ public static Task Main(string[] args) var scanProjectDeprecated = new Option(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(new[] { "--out", }, description: "(Deprecated use -output instead) The directory to write the BOM"); - RootCommand rootCommand = new RootCommand { SolutionOrProjectFile, @@ -101,7 +101,8 @@ public static Task 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) => @@ -136,7 +137,9 @@ public static Task 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(); diff --git a/CycloneDX/Services/NugetV3Service.cs b/CycloneDX/Services/NugetV3Service.cs index e1d4feb9..237eb914 100644 --- a/CycloneDX/Services/NugetV3Service.cs +++ b/CycloneDX/Services/NugetV3Service.cs @@ -50,9 +50,13 @@ public class NugetV3Service : INugetService private readonly IFileSystem _fileSystem; private readonly List _packageCachePaths; private readonly bool _disableHashComputation; + private readonly EvidenceLicenseTextCollectionMode _evidenceCollectionMode; // Used in local files private const string _nuspecExtension = ".nuspec"; + private readonly List _licenseFiles = new() { + "LICENSE.txt", "LICENCE.txt", "LICENCE.md", "LICENSE.md", "LICENSE", "LICENCE" + }; private const string _nupkgExtension = ".nupkg"; private const string _sha512Extension = ".nupkg.sha512"; @@ -62,13 +66,15 @@ public NugetV3Service( List 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); @@ -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; + } + /// /// Normalize the version string according to /// https://learn.microsoft.com/en-us/nuget/concepts/package-versioning#normalized-version-numbers @@ -269,11 +311,13 @@ public async Task 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 { new LicenseChoice { License = license } }; } else @@ -309,7 +353,7 @@ public async Task 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 { new LicenseChoice { License = license } }; @@ -350,6 +394,74 @@ public async Task GetComponentAsync(string name, string version, Comp return component; } + private async Task 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 { 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 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 { new OrganizationalContact { Name = nuspecModel.nuspecReader.GetAuthors() } }; diff --git a/CycloneDX/Services/NugetV3ServiceFactory.cs b/CycloneDX/Services/NugetV3ServiceFactory.cs index 46479336..82fc7f18 100644 --- a/CycloneDX/Services/NugetV3ServiceFactory.cs +++ b/CycloneDX/Services/NugetV3ServiceFactory.cs @@ -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); } } } diff --git a/README.md b/README.md index 5a2cd5b9..267d70fa 100755 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Options: -st, --set-type + -cle, --collect-license-evidence [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 @@ -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.