diff --git a/src/cyclonedx/CliUtils.cs b/src/cyclonedx/CliUtils.cs index 1f2911e..748ef4e 100644 --- a/src/cyclonedx/CliUtils.cs +++ b/src/cyclonedx/CliUtils.cs @@ -22,6 +22,7 @@ using CycloneDX.Spdx.Interop; using CycloneDX.Cli.Commands; using CycloneDX.Cli.Serialization; +using CycloneDX.Utils; namespace CycloneDX.Cli { @@ -174,7 +175,7 @@ public static async Task OutputBomHelper(Bom bom, CycloneDXBomFormat format return 0; } - public static async Task OutputBomHelper(Bom bom, ConvertFormat format, SpecificationVersion? outputVersion, string filename) + public static async Task OutputBomHelper(Bom bom, ConvertFormat format, SpecificationVersion? outputVersion, string filename, bool stripEmptyLists = false) { if (filename == null && format == ConvertFormat.autodetect) { @@ -193,6 +194,16 @@ public static async Task OutputBomHelper(Bom bom, ConvertFormat format, Spe bom.SpecVersion = outputVersion.HasValue ? outputVersion.Value : SpecificationVersionHelpers.CurrentVersion; + // Downgrading to an older spec version already collapses empty + // (non-null, zero-count) lists to null as a side effect of the + // protobuf deep-copy CopyBomAndDowngrade uses -- but the current + // spec version is serialized without going through that copy, + // so its empty lists survive unless pruned here explicitly. + if (stripEmptyLists) + { + CycloneDXUtils.CleanupEmptyListsDeep(bom); + } + using var stream = filename == null ? Console.OpenStandardOutput() : File.Create(filename); switch (format) diff --git a/src/cyclonedx/Commands/ConvertCommand.cs b/src/cyclonedx/Commands/ConvertCommand.cs index 14e4e7e..25754f7 100644 --- a/src/cyclonedx/Commands/ConvertCommand.cs +++ b/src/cyclonedx/Commands/ConvertCommand.cs @@ -35,6 +35,7 @@ internal static void Configure(RootCommand rootCommand) subCommand.Add(new Option("--input-format", "Specify input file format.")); subCommand.Add(new Option("--output-format", "Specify output file format.")); subCommand.Add(new Option("--output-version", "Specify output BOM specification version. (ignored for CSV and SPDX formats)")); + subCommand.Add(new Option("--strip-empty-lists", "Omit empty list properties (e.g. \"licenses\": [], \"dependsOn\": []) from the output instead of writing them out. Schema-valid either way; this just avoids redundant clutter.")); subCommand.Handler = CommandHandler.Create(Convert); rootCommand.Add(subCommand); } @@ -62,7 +63,7 @@ public static async Task Convert(ConvertCommandOptions options) } } - return await CliUtils.OutputBomHelper(inputBom, options.OutputFormat, options.OutputVersion, options.OutputFile).ConfigureAwait(false); + return await CliUtils.OutputBomHelper(inputBom, options.OutputFormat, options.OutputVersion, options.OutputFile, options.StripEmptyLists).ConfigureAwait(false); } } } diff --git a/src/cyclonedx/Commands/ConvertCommandOptions.cs b/src/cyclonedx/Commands/ConvertCommandOptions.cs index 856c969..008f4b7 100644 --- a/src/cyclonedx/Commands/ConvertCommandOptions.cs +++ b/src/cyclonedx/Commands/ConvertCommandOptions.cs @@ -24,5 +24,6 @@ internal class ConvertCommandOptions public ConvertFormat InputFormat { get; set; } public ConvertFormat OutputFormat { get; set; } public SpecificationVersion? OutputVersion { get; set; } + public bool StripEmptyLists { get; set; } } } \ No newline at end of file diff --git a/src/cyclonedx/Commands/MergeCommand.cs b/src/cyclonedx/Commands/MergeCommand.cs index 6c61bc1..05a304c 100644 --- a/src/cyclonedx/Commands/MergeCommand.cs +++ b/src/cyclonedx/Commands/MergeCommand.cs @@ -19,6 +19,7 @@ using System.Diagnostics.Contracts; using System.CommandLine; using System.CommandLine.Invocation; +using System.IO; using System.Threading.Tasks; using CycloneDX.Models; using CycloneDX.Utils; @@ -34,6 +35,8 @@ public static void Configure(RootCommand rootCommand) var subCommand = new System.CommandLine.Command("merge", "Merge two or more BOMs") { new Option>("--input-files", "Input BOM filenames (separate filenames with a space).") { AllowMultipleArgumentsPerToken = true }, + new Option>("--input-files-list", "One or more text file(s) with input BOM filenames (one per line). Combined with --input-files, useful to exceed OS/shell command-line length limits when merging many BOMs.") { AllowMultipleArgumentsPerToken = true }, + new Option>("--input-files-nul-list", "One or more text-like file(s) with input BOM filenames (separated by 0x00 characters, e.g. from `find -print0`).") { AllowMultipleArgumentsPerToken = true }, new Option("--output-file", "Output BOM filename, will write to stdout if no value provided."), new Option("--input-format", "Specify input file format."), new Option("--output-format", "Specify output file format."), @@ -41,7 +44,15 @@ public static void Configure(RootCommand rootCommand) new Option("--hierarchical", "Perform a hierarchical merge."), new Option("--group", "Provide the group of software the merged BOM describes."), new Option("--name", "Provide the name of software the merged BOM describes (required for hierarchical merging)."), - new Option("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging).") + new Option("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging)."), + new Option("--validate-output", "Validate the merged document before writing it, and do not write it if validation fails."), + new Option("--validate-output-relaxed", "Validate the merged document, but still write it (for troubleshooting) even if validation fails."), + new Option("--strip-empty-lists", "Omit empty list properties (e.g. \"licenses\": [], \"dependsOn\": []) from the output instead of writing them out. Schema-valid either way; this just avoids redundant clutter."), +#if NET8_0_OR_GREATER + new Option("--component-conflict-resolution", "How to resolve two equivalent (same type/name/version/group/purl) but not-identical Components, e.g. differing only by Scope. Default: squash, preferring the more permissive Scope."), + new Option("--attach-dangling-components", "Attach any components no dependsOn edge reaches (grouped by Scope into synthetic components) so consumers that walk the dependency graph from the subject, rather than scanning the flat components list, don't silently miss them."), + new Option("--attach-dangling-components-ref", "Existing bom-ref to attach dangling components under (with --attach-dangling-components). Defaults to the merge subject if not given or not found."), +#endif }; subCommand.Handler = CommandHandler.Create(Merge); rootCommand.Add(subCommand); @@ -65,7 +76,7 @@ public static async Task Merge(MergeCommandOptions options) return (int)ExitCode.ParameterValidationError; } - var inputBoms = await InputBoms(options.InputFiles, options.InputFormat, outputToConsole).ConfigureAwait(false); + var inputBoms = await InputBoms(DetermineInputFiles(options), options.InputFormat, outputToConsole).ConfigureAwait(false); Component bomSubject = null; if (options.Group != null || options.Name != null || options.Version != null) @@ -77,23 +88,37 @@ public static async Task Merge(MergeCommandOptions options) Version = options.Version, }; +#if NET8_0_OR_GREATER + var mergeStrategy = MergeStrategy.Default(); + if (options.ComponentConflictResolution.HasValue) + { + mergeStrategy.ComponentConflictResolution = options.ComponentConflictResolution.Value; + } +#endif + Bom outputBom; if (options.Hierarchical) { +#if NET8_0_OR_GREATER + outputBom = CycloneDXUtils.HierarchicalMerge(inputBoms, bomSubject, mergeStrategy); +#else outputBom = CycloneDXUtils.HierarchicalMerge(inputBoms, bomSubject); +#endif } else { - outputBom = CycloneDXUtils.FlatMerge(inputBoms); +#if NET8_0_OR_GREATER + outputBom = CycloneDXUtils.FlatMerge(inputBoms, bomSubject, mergeStrategy); +#else + outputBom = CycloneDXUtils.FlatMerge(inputBoms, bomSubject); +#endif if (outputBom.Metadata is null) outputBom.Metadata = new Metadata(); - if (bomSubject != null) - { - // use the params provided if possible - outputBom.Metadata.Component = bomSubject; - } - else + if (bomSubject is null) { - // otherwise use the first non-null component from the input BOMs as the default + // otherwise use the first non-null component from the input + // BOMs as the default; note CleanupMetadataComponent below, + // since that same component may also already be present + // in outputBom.Components. foreach (var bom in inputBoms) { if(bom.Metadata != null && bom.Metadata.Component != null) @@ -105,6 +130,43 @@ public static async Task Merge(MergeCommandOptions options) } } +#if NET8_0_OR_GREATER + outputBom = CycloneDXUtils.CleanupMetadataComponent(outputBom, mergeStrategy); + outputBom = CycloneDXUtils.CleanupEmptyLists(outputBom); + + if (options.AttachDanglingComponents) + { + var attached = outputBom.AttachDanglingComponents(options.AttachDanglingComponentsRef); + if (attached.Count > 0) + { + var totalAttached = 0; + foreach (var bucket in attached.Values) totalAttached += bucket.Count; + Console.WriteLine($"Attached {totalAttached} component(s) unreachable from the dependency graph, in {attached.Count} scope bucket(s):"); + foreach (var bucket in attached) + { + Console.WriteLine($" {bucket.Key}: {bucket.Value.Count} component(s)"); + } + } + } +#endif + + // FlatMerge/HierarchicalMerge never set SpecVersion on their + // result, so it defaults to v1_0 unless assigned here. Apply the + // requested --output-version (or the library's current version, + // matching OutputBomHelper's own default) before the + // --validate-output check below, so validation reflects the + // spec version that will actually be written -- rather than + // OutputBomHelper silently overriding it afterwards, by which + // point validation has already run against the wrong target. + outputBom.SpecVersion = options.OutputVersion ?? SpecificationVersionHelpers.CurrentVersion; + + // Ensure that the merged document has its own identity (new + // SerialNumber, Version=1, Timestamp...) and that its Tools + // collection records the library and program that produced it. +#if NET8_0_OR_GREATER + outputBom.BomMetadataUpdate(true); + outputBom.BomMetadataReferThisToolkit(); +#else outputBom.Version = 1; outputBom.SerialNumber = "urn:uuid:" + System.Guid.NewGuid().ToString(); if (outputBom.Metadata == null) @@ -115,6 +177,43 @@ public static async Task Merge(MergeCommandOptions options) { outputBom.Metadata.Timestamp = DateTime.Now; } +#endif + + if (options.StripEmptyLists) + { + // Applied before validation so the validated document + // matches what OutputBomHelper actually writes below. + CycloneDXUtils.CleanupEmptyListsDeep(outputBom); + } + + ValidationResult validationResult = null; + if (options.ValidateOutput || options.ValidateOutputRelaxed) + { + Console.WriteLine("Validating merged BOM..."); + validationResult = Json.Validator.Validate(Json.Serializer.Serialize(outputBom), outputBom.SpecVersion); + + if (validationResult.Messages != null) + { + foreach (var message in validationResult.Messages) + { + Console.WriteLine(message); + } + } + + if (validationResult.Valid) + { + Console.WriteLine("Merged BOM validated successfully."); + } + else + { + Console.WriteLine("Merged BOM is not valid."); + if (!options.ValidateOutputRelaxed) + { + Console.WriteLine("NOT writing output file..."); + return (int)ExitCode.SignatureFailedVerification; + } + } + } if (!outputToConsole) { @@ -122,7 +221,68 @@ public static async Task Merge(MergeCommandOptions options) Console.WriteLine($" Total {outputBom.Components?.Count ?? 0} components"); } - return await CliUtils.OutputBomHelper(outputBom, (ConvertFormat)options.OutputFormat, options.OutputVersion, options.OutputFile).ConfigureAwait(false); + var res = await CliUtils.OutputBomHelper(outputBom, (ConvertFormat)options.OutputFormat, options.OutputVersion, options.OutputFile, options.StripEmptyLists).ConfigureAwait(false); + if (validationResult != null && !validationResult.Valid) + { + // Relaxed mode: the file was still written above, but the + // command as a whole should still report failure. + return (int)ExitCode.SignatureFailedVerification; + } + return res; + } + + /// + /// Combines --input-files with any filenames listed inside + /// --input-files-list (one per line) and --input-files-nul-list + /// (0x00-separated) files, deduplicating as it goes. Lets callers + /// exceed OS/shell command-line length or argument-count limits + /// when merging many BOMs, by passing a generated list file + /// instead of one --input-files argument per BOM. + /// + private static List DetermineInputFiles(MergeCommandOptions options) + { + var inputFiles = options.InputFiles != null ? new List(options.InputFiles) : new List(); + + if (options.InputFilesList != null) + { + foreach (var oneList in options.InputFilesList) + { + Console.WriteLine($"Adding to input file list from {oneList}"); + var count = 0; + foreach (var line in File.ReadAllLines(oneList)) + { + if (string.IsNullOrEmpty(line) || inputFiles.Contains(line)) + { + continue; + } + inputFiles.Add(line); + count++; + } + Console.WriteLine($"Got {count} new entries from {oneList}"); + } + } + + if (options.InputFilesNulList != null) + { + foreach (var oneList in options.InputFilesNulList) + { + Console.WriteLine($"Adding to input file list from {oneList}"); + var count = 0; + foreach (var line in File.ReadAllText(oneList).Split('\0')) + { + if (string.IsNullOrEmpty(line) || inputFiles.Contains(line)) + { + continue; + } + inputFiles.Add(line); + count++; + } + Console.WriteLine($"Got {count} new entries from {oneList}"); + } + } + + Console.WriteLine($"Determined {inputFiles.Count} input file(s) to merge"); + return inputFiles; } private static async Task> InputBoms(IEnumerable inputFilenames, CycloneDXBomFormat inputFormat, bool outputToConsole) diff --git a/src/cyclonedx/Commands/MergeCommandOptions.cs b/src/cyclonedx/Commands/MergeCommandOptions.cs index 29d734a..6feda4f 100644 --- a/src/cyclonedx/Commands/MergeCommandOptions.cs +++ b/src/cyclonedx/Commands/MergeCommandOptions.cs @@ -15,12 +15,15 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright (c) OWASP Foundation. All Rights Reserved. using System.Collections.Generic; +using CycloneDX.Models; namespace CycloneDX.Cli.Commands { internal class MergeCommandOptions { public IList InputFiles { get; set; } + public IList InputFilesList { get; set; } + public IList InputFilesNulList { get; set; } public string OutputFile { get; set; } public CycloneDXBomFormat InputFormat { get; set; } public CycloneDXBomFormat OutputFormat { get; set; } @@ -29,5 +32,13 @@ internal class MergeCommandOptions public string Group { get; set; } public string Name { get; set; } public string Version { get; set; } + public bool ValidateOutput { get; set; } + public bool ValidateOutputRelaxed { get; set; } + public bool StripEmptyLists { get; set; } +#if NET8_0_OR_GREATER + public ComponentConflictResolution? ComponentConflictResolution { get; set; } + public bool AttachDanglingComponents { get; set; } + public string AttachDanglingComponentsRef { get; set; } +#endif } } diff --git a/src/cyclonedx/Commands/RenameEntityCommand.cs b/src/cyclonedx/Commands/RenameEntityCommand.cs new file mode 100644 index 0000000..011d8b7 --- /dev/null +++ b/src/cyclonedx/Commands/RenameEntityCommand.cs @@ -0,0 +1,103 @@ +// This file is part of CycloneDX CLI Tool +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. +#if NET8_0_OR_GREATER +using System; +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; +using System.Diagnostics.Contracts; +using System.Threading.Tasks; + +namespace CycloneDX.Cli.Commands +{ + internal static class RenameEntityCommand + { + internal static void Configure(RootCommand rootCommand) + { + Contract.Requires(rootCommand != null); + var subCommand = new Command("rename-entity", "Rename an entity identified by a \"bom-ref\" (including back-references to it) in the BOM document"); + subCommand.Add(new Option("--input-file", "Input BOM filename.")); + subCommand.Add(new Option("--output-file", "Output BOM filename, will write to stdout if no value provided.")); + subCommand.Add(new Option("--old-ref", "Old value of \"bom-ref\" entity identifier (or \"ref\" values or certain list items pointing to it).")); + subCommand.Add(new Option("--new-ref", "New value of \"bom-ref\" entity identifier (or \"ref\" values or certain list items pointing to it).")); + subCommand.Add(new Option("--input-format", "Specify input file format.")); + subCommand.Add(new Option("--output-format", "Specify output file format.")); + subCommand.Handler = CommandHandler.Create(RenameEntity); + rootCommand.Add(subCommand); + } + + public static async Task RenameEntity(RenameEntityCommandOptions options) + { + Contract.Requires(options != null); + var outputToConsole = string.IsNullOrEmpty(options.OutputFile); + + if (options.OutputFormat == CycloneDXBomFormat.autodetect) + { + options.OutputFormat = CliUtils.AutoDetectBomFormat(options.OutputFile); + if (options.OutputFormat == CycloneDXBomFormat.autodetect) + { + Console.WriteLine($"Unable to auto-detect output format"); + return (int)ExitCode.ParameterValidationError; + } + } + + Console.WriteLine($"Loading input document..."); + if (!outputToConsole) Console.WriteLine($"Processing input file {options.InputFile}"); + var bom = await CliUtils.InputBomHelper(options.InputFile, options.InputFormat).ConfigureAwait(false); + + if (bom is null) + { + Console.WriteLine($"Empty or absent input document"); + return (int)ExitCode.ParameterValidationError; + } + + Console.WriteLine($"Renaming \"{options.OldRef}\" to \"{options.NewRef}\" (this can take a while)"); + try + { + if (bom.RenameRef(options.OldRef, options.NewRef)) + { + Console.WriteLine($"Did not encounter any issues during the rename operation"); + } + else + { + Console.WriteLine($"Rename operation found nothing to do (e.g. old ref name not mentioned in the Bom document)"); + } + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Rename operation refused: {ex.Message}"); + return (int)ExitCode.ParameterValidationError; + } + + // Ensure that the modified document has its own identity + // (new SerialNumber, Version=1, Timestamp...) and its Tools + // collection refers to this library and the program/tool + // like cyclonedx-cli which consumes it: + bom.BomMetadataUpdate(true); + bom.BomMetadataReferThisToolkit(); + + if (!outputToConsole) + { + Console.WriteLine("Writing output file..."); + Console.WriteLine($" Total {bom.Components?.Count ?? 0} components, {bom.Dependencies?.Count ?? 0} dependencies"); + } + + int res = await CliUtils.OutputBomHelper(bom, options.OutputFormat, options.OutputFile).ConfigureAwait(false); + return res; + } + } +} +#endif diff --git a/src/cyclonedx/Commands/RenameEntityCommandOptions.cs b/src/cyclonedx/Commands/RenameEntityCommandOptions.cs new file mode 100644 index 0000000..e07f872 --- /dev/null +++ b/src/cyclonedx/Commands/RenameEntityCommandOptions.cs @@ -0,0 +1,31 @@ +// This file is part of CycloneDX CLI Tool +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +#if NET8_0_OR_GREATER +namespace CycloneDX.Cli.Commands +{ + internal class RenameEntityCommandOptions + { + public string InputFile { get; set; } + public string OutputFile { get; set; } + public string OldRef { get; set; } + public string NewRef { get; set; } + public CycloneDXBomFormat InputFormat { get; set; } + public CycloneDXBomFormat OutputFormat { get; set; } + } +} +#endif diff --git a/src/cyclonedx/Program.cs b/src/cyclonedx/Program.cs index 237b4ae..7e6b91d 100644 --- a/src/cyclonedx/Program.cs +++ b/src/cyclonedx/Program.cs @@ -47,6 +47,9 @@ public static async Task Main(string[] args) DiffCommand.Configure(rootCommand); KeyGenCommand.Configure(rootCommand); MergeCommand.Configure(rootCommand); +#if NET8_0_OR_GREATER + RenameEntityCommand.Configure(rootCommand); +#endif SignCommand.Configure(rootCommand); ValidateCommand.Configure(rootCommand); VerifyCommand.Configure(rootCommand); diff --git a/tests/cyclonedx.tests/AttachDanglingComponentsTests.cs b/tests/cyclonedx.tests/AttachDanglingComponentsTests.cs new file mode 100644 index 0000000..dc8769b --- /dev/null +++ b/tests/cyclonedx.tests/AttachDanglingComponentsTests.cs @@ -0,0 +1,119 @@ +// This file is part of CycloneDX CLI Tool +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. +#if NET8_0_OR_GREATER +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; +using CycloneDX.Cli.Commands; + +namespace CycloneDX.Cli.Tests +{ + public class AttachDanglingComponentsTests + { + [Fact] + public async Task Merge_AttachDanglingComponents_ClosesTheDependencyGraph() + { + using (var tempDirectory = new TempDirectory()) + { + var fullOutputPath = Path.Join(tempDirectory.DirectoryPath, "sbom.json"); + var options = new MergeCommandOptions + { + InputFiles = new List { Path.Combine("Resources", "AttachDanglingComponents", "sbom1.json") }, + InputFormat = CycloneDXBomFormat.autodetect, + OutputFile = fullOutputPath, + OutputFormat = CycloneDXBomFormat.autodetect, + AttachDanglingComponents = true, + }; + + var exitCode = await MergeCommand.Merge(options).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + using var doc = JsonDocument.Parse(File.ReadAllText(fullOutputPath)); + var root = doc.RootElement; + + var componentRefs = new HashSet(); + foreach (var c in root.GetProperty("components").EnumerateArray()) + { + componentRefs.Add(c.GetProperty("bom-ref").GetString()); + } + + // orphan-lib (required) and orphan-test-lib (excluded) had no + // incoming dependsOn edge -- each should now have its own + // scope-bucketed attachment component. + Assert.Contains("unreferenced-components:scope=Required", componentRefs); + Assert.Contains("unreferenced-components:scope=Excluded", componentRefs); + + var outgoing = new Dictionary>(); + foreach (var d in root.GetProperty("dependencies").EnumerateArray()) + { + var list = new List(); + if (d.TryGetProperty("dependsOn", out var dependsOn)) + { + foreach (var t in dependsOn.EnumerateArray()) list.Add(t.GetString()); + } + outgoing[d.GetProperty("ref").GetString()] = list; + } + + // Every component must now be reachable from the subject via + // a directed walk of dependsOn edges. + var seen = new HashSet { "app" }; + var stack = new Stack(); + stack.Push("app"); + while (stack.Count > 0) + { + var current = stack.Pop(); + if (!outgoing.TryGetValue(current, out var children)) continue; + foreach (var child in children) + { + if (seen.Add(child)) stack.Push(child); + } + } + + foreach (var bomRef in componentRefs) + { + Assert.True(seen.Contains(bomRef), $"'{bomRef}' is not reachable from the subject after AttachDanglingComponents"); + } + } + } + + [Fact] + public async Task Merge_WithoutAttachDanglingComponents_LeavesGraphAsIs() + { + using (var tempDirectory = new TempDirectory()) + { + var fullOutputPath = Path.Join(tempDirectory.DirectoryPath, "sbom.json"); + var options = new MergeCommandOptions + { + InputFiles = new List { Path.Combine("Resources", "AttachDanglingComponents", "sbom1.json") }, + InputFormat = CycloneDXBomFormat.autodetect, + OutputFile = fullOutputPath, + OutputFormat = CycloneDXBomFormat.autodetect, + AttachDanglingComponents = false, + }; + + var exitCode = await MergeCommand.Merge(options).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + var bom = File.ReadAllText(fullOutputPath); + Assert.DoesNotContain("unreferenced-components", bom); + } + } + } +} +#endif diff --git a/tests/cyclonedx.tests/MergeTests.cs b/tests/cyclonedx.tests/MergeTests.cs index 56b9c62..b09d3dc 100644 --- a/tests/cyclonedx.tests/MergeTests.cs +++ b/tests/cyclonedx.tests/MergeTests.cs @@ -79,6 +79,12 @@ public async Task Merge( bom = Regex.Replace(bom, @"\s+serialNumber="".*?""", ""); // xml bom = Regex.Replace(bom, @"\s*""timestamp"": "".*?"",\r?\n", ""); // json bom = Regex.Replace(bom, @"\s+.*?", ""); // xml + // The tools list embeds this build's assembly names/versions + // (e.g. "testhost" under `dotnet test` vs. the real CLI + // executable otherwise), which are environment-specific -- + // strip the whole block before snapshotting. + bom = Regex.Replace(bom, @"\s*""tools"":\s*\[.*?\],?", "", RegexOptions.Singleline); // json + bom = Regex.Replace(bom, @"\s*.*?", "", RegexOptions.Singleline); // xml Snapshot.Match(bom, SnapshotNameExtension.Create(hierarchical ? "Hierarchical" : "Flat", snapshotInputFilenames, inputFormat, outputFilename, outputFormat, outputVersion)); } } diff --git a/tests/cyclonedx.tests/RenameEntityTests.cs b/tests/cyclonedx.tests/RenameEntityTests.cs new file mode 100644 index 0000000..6387bc2 --- /dev/null +++ b/tests/cyclonedx.tests/RenameEntityTests.cs @@ -0,0 +1,100 @@ +// This file is part of CycloneDX CLI Tool +// +// Licensed under the Apache License, Version 2.0 (the “License”); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an “AS IS” BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. +#if NET8_0_OR_GREATER +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Xunit; +using Snapshooter; +using Snapshooter.Xunit; +using CycloneDX.Cli.Commands; + +namespace CycloneDX.Cli.Tests +{ + public class RenameEntityTests + { + [Theory] + [InlineData("sbom1.json", CycloneDXBomFormat.autodetect, "sbom.json", CycloneDXBomFormat.autodetect)] + [InlineData("sbom1.json", CycloneDXBomFormat.json, "sbom.xml", CycloneDXBomFormat.autodetect)] + public async Task RenameEntity_RewritesIdentifierAndBackReferences( + string inputFilename, + CycloneDXBomFormat inputFormat, + string outputFilename, + CycloneDXBomFormat outputFormat + ) + { + using (var tempDirectory = new TempDirectory()) + { + var fullOutputPath = Path.Join(tempDirectory.DirectoryPath, outputFilename); + var options = new RenameEntityCommandOptions + { + InputFile = Path.Combine("Resources", "RenameEntity", inputFilename), + InputFormat = inputFormat, + OutputFile = fullOutputPath, + OutputFormat = outputFormat, + OldRef = "lib-old", + NewRef = "lib-new", + }; + + var exitCode = await RenameEntityCommand.RenameEntity(options).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + var bom = File.ReadAllText(fullOutputPath); + bom = Regex.Replace(bom, @"\s*""serialNumber"": "".*?"",\r?\n", ""); // json + bom = Regex.Replace(bom, @"\s+serialNumber="".*?""", ""); // xml + bom = Regex.Replace(bom, @"\s*""timestamp"": "".*?"",\r?\n", ""); // json + bom = Regex.Replace(bom, @"\s+.*?", ""); // xml + // The tools list embeds this build's assembly names/versions + // (e.g. "testhost" under `dotnet test` vs. the real CLI + // executable otherwise), which are environment-specific -- + // strip the whole block before snapshotting. + bom = Regex.Replace(bom, @"\s*""tools"":\s*\[.*?\],?", "", RegexOptions.Singleline); // json + bom = Regex.Replace(bom, @"\s*.*?", "", RegexOptions.Singleline); // xml + + Assert.DoesNotContain("lib-old", bom); + Assert.Contains("lib-new", bom); + Snapshot.Match(bom, SnapshotNameExtension.Create(inputFilename, inputFormat, outputFilename, outputFormat)); + } + } + + [Fact] + public async Task RenameEntity_NoOp_WhenOldRefNotPresent() + { + using (var tempDirectory = new TempDirectory()) + { + var fullOutputPath = Path.Join(tempDirectory.DirectoryPath, "sbom.json"); + var options = new RenameEntityCommandOptions + { + InputFile = Path.Combine("Resources", "RenameEntity", "sbom1.json"), + InputFormat = CycloneDXBomFormat.autodetect, + OutputFile = fullOutputPath, + OutputFormat = CycloneDXBomFormat.autodetect, + OldRef = "does-not-exist", + NewRef = "lib-new", + }; + + var exitCode = await RenameEntityCommand.RenameEntity(options).ConfigureAwait(false); + + Assert.Equal(0, exitCode); + var bom = File.ReadAllText(fullOutputPath); + Assert.Contains("lib-old", bom); + Assert.DoesNotContain("lib-new", bom); + } + } + } +} +#endif diff --git a/tests/cyclonedx.tests/Resources/AttachDanglingComponents/sbom1.json b/tests/cyclonedx.tests/Resources/AttachDanglingComponents/sbom1.json new file mode 100644 index 0000000..6f42dd9 --- /dev/null +++ b/tests/cyclonedx.tests/Resources/AttachDanglingComponents/sbom1.json @@ -0,0 +1,47 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "app", + "name": "app", + "version": "1" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "linked-lib", + "name": "linked-lib", + "version": "1.0.0", + "scope": "required" + }, + { + "type": "library", + "bom-ref": "orphan-lib", + "name": "orphan-lib", + "version": "1.0.0", + "scope": "required" + }, + { + "type": "library", + "bom-ref": "orphan-test-lib", + "name": "orphan-test-lib", + "version": "1.0.0", + "scope": "excluded" + } + ], + "dependencies": [ + { + "ref": "app", + "dependsOn": ["linked-lib"] + }, + { + "ref": "linked-lib", + "dependsOn": [] + } + ] +} diff --git a/tests/cyclonedx.tests/Resources/RenameEntity/sbom1.json b/tests/cyclonedx.tests/Resources/RenameEntity/sbom1.json new file mode 100644 index 0000000..49cf30b --- /dev/null +++ b/tests/cyclonedx.tests/Resources/RenameEntity/sbom1.json @@ -0,0 +1,26 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "app-1", + "name": "thing1", + "version": "1" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "lib-old", + "name": "acme-library", + "version": "1.0.0" + } + ], + "dependencies": [ + { "ref": "app-1", "dependsOn": ["lib-old"] }, + { "ref": "lib-old", "dependsOn": [] } + ] +} diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap index 3858ba3..61351ab 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1" diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap index 3858ba3..61351ab 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_autodetect_sbom.json_json_.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1" diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap index 3858ba3..61351ab 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1" diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap index 00f59bb..31cf7f5 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.json_sbom2.json_json_sbom.json_autodetect_v1_4.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.4", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1" diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap index 1a5e2df..634d23b 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Flat_sbom1.xml_sbom2.xml_autodetect_sbom.json_autodetect_.snap @@ -1,7 +1,8 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "component": { + "metadata": { + "component": { "type": "application", "name": "thing1", "version": "1", @@ -33,7 +34,5 @@ "version": "1", "patentAssertions": [] } - ], - "vulnerabilities": [], - "annotations": [] + ] } diff --git a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Hierarchical_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Hierarchical_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap index bea7dfc..7175663 100644 --- a/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Hierarchical_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap +++ b/tests/cyclonedx.tests/__snapshots__/MergeTests.Merge_Hierarchical_sbom1.json_sbom2.json_autodetect_sbom.json_autodetect_.snap @@ -1,7 +1,7 @@ { "bomFormat": "CycloneDX", "specVersion": "1.7", "version": 1, - "metadata": { "tools": {}, + "metadata": { "component": { "type": "application", "bom-ref": "Thing@1", diff --git a/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_autodetect_sbom.json_autodetect.snap b/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_autodetect_sbom.json_autodetect.snap new file mode 100644 index 0000000..a20decf --- /dev/null +++ b/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_autodetect_sbom.json_autodetect.snap @@ -0,0 +1,31 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "app-1", + "name": "thing1", + "version": "1" + } + }, + "components": [ + { + "type": "library", + "bom-ref": "lib-new", + "name": "acme-library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "app-1", + "dependsOn": [ + "lib-new" + ] + }, + { + "ref": "lib-new" + } + ] +} diff --git a/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_json_sbom.xml_autodetect.snap b/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_json_sbom.xml_autodetect.snap new file mode 100644 index 0000000..d883e99 --- /dev/null +++ b/tests/cyclonedx.tests/__snapshots__/RenameEntityTests.RenameEntity_RewritesIdentifierAndBackReferences_sbom1.json_json_sbom.xml_autodetect.snap @@ -0,0 +1,21 @@ + + + + + thing1 + 1 + + + + + acme-library + 1.0.0 + + + + + + + + +