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
157 changes: 146 additions & 11 deletions src/cyclonedx/Commands/MergeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,14 +35,21 @@ public static void Configure(RootCommand rootCommand)
var subCommand = new System.CommandLine.Command("merge", "Merge two or more BOMs")
{
new Option<List<string>>("--input-files", "Input BOM filenames (separate filenames with a space).") { AllowMultipleArgumentsPerToken = true },
new Option<List<string>>("--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<List<string>>("--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<string>("--output-file", "Output BOM filename, will write to stdout if no value provided."),
new Option<CycloneDXBomFormat>("--input-format", "Specify input file format."),
new Option<CycloneDXBomFormat>("--output-format", "Specify output file format."),
new Option<SpecificationVersion>("--output-version", "Specify output BOM specification version."),
new Option<bool>("--hierarchical", "Perform a hierarchical merge."),
new Option<string>("--group", "Provide the group of software the merged BOM describes."),
new Option<string>("--name", "Provide the name of software the merged BOM describes (required for hierarchical merging)."),
new Option<string>("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging).")
new Option<string>("--version", "Provide the version of software the merged BOM describes (required for hierarchical merging)."),
new Option<bool>("--validate-output", "Validate the merged document before writing it, and do not write it if validation fails."),
new Option<bool>("--validate-output-relaxed", "Validate the merged document, but still write it (for troubleshooting) even if validation fails."),
#if NET8_0_OR_GREATER
new Option<ComponentConflictResolution>("--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."),
#endif
};
subCommand.Handler = CommandHandler.Create<MergeCommandOptions>(Merge);
rootCommand.Add(subCommand);
Expand All @@ -65,7 +73,7 @@ public static async Task<int> 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)
Expand All @@ -77,23 +85,37 @@ public static async Task<int> 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)
Expand All @@ -105,6 +127,28 @@ public static async Task<int> Merge(MergeCommandOptions options)
}
}

#if NET8_0_OR_GREATER
outputBom = CycloneDXUtils.CleanupMetadataComponent(outputBom, mergeStrategy);
outputBom = CycloneDXUtils.CleanupEmptyLists(outputBom);
#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)
Expand All @@ -115,14 +159,105 @@ public static async Task<int> Merge(MergeCommandOptions options)
{
outputBom.Metadata.Timestamp = DateTime.Now;
}
#endif

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)
{
Console.WriteLine("Writing output file...");
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).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;
}

/// <summary>
/// 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.
/// </summary>
private static List<string> DetermineInputFiles(MergeCommandOptions options)
{
var inputFiles = options.InputFiles != null ? new List<string>(options.InputFiles) : new List<string>();

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<IEnumerable<Bom>> InputBoms(IEnumerable<string> inputFilenames, CycloneDXBomFormat inputFormat, bool outputToConsole)
Expand Down
8 changes: 8 additions & 0 deletions src/cyclonedx/Commands/MergeCommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> InputFiles { get; set; }
public IList<string> InputFilesList { get; set; }
public IList<string> InputFilesNulList { get; set; }
public string OutputFile { get; set; }
public CycloneDXBomFormat InputFormat { get; set; }
public CycloneDXBomFormat OutputFormat { get; set; }
Expand All @@ -29,5 +32,10 @@ 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; }
#if NET8_0_OR_GREATER
public ComponentConflictResolution? ComponentConflictResolution { get; set; }
#endif
}
}
103 changes: 103 additions & 0 deletions src/cyclonedx/Commands/RenameEntityCommand.cs
Original file line number Diff line number Diff line change
@@ -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<string>("--input-file", "Input BOM filename."));
subCommand.Add(new Option<string>("--output-file", "Output BOM filename, will write to stdout if no value provided."));
subCommand.Add(new Option<string>("--old-ref", "Old value of \"bom-ref\" entity identifier (or \"ref\" values or certain list items pointing to it)."));
subCommand.Add(new Option<string>("--new-ref", "New value of \"bom-ref\" entity identifier (or \"ref\" values or certain list items pointing to it)."));
subCommand.Add(new Option<CycloneDXBomFormat>("--input-format", "Specify input file format."));
subCommand.Add(new Option<CycloneDXBomFormat>("--output-format", "Specify output file format."));
subCommand.Handler = CommandHandler.Create<RenameEntityCommandOptions>(RenameEntity);
rootCommand.Add(subCommand);
}

public static async Task<int> 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
31 changes: 31 additions & 0 deletions src/cyclonedx/Commands/RenameEntityCommandOptions.cs
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions src/cyclonedx/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public static async Task<int> 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);
Expand Down
Loading