diff --git a/docs/multitool-usage.md b/docs/multitool-usage.md index 3ab92cab9..151f880c2 100644 --- a/docs/multitool-usage.md +++ b/docs/multitool-usage.md @@ -11,10 +11,11 @@ Use the SARIF Multitool to rewrite, enrich, filter, result match, and do other c | file-work-items | Send SARIF results to a work item tracking system such as GitHub or Azure DevOps | | match-results-forward | Match Results run over run to identify New, Absent, and Unchanged Results | | merge | Merge multiple SARIF files into one | -| page | Extract a subset of results from a source SARIF file. | -| query | Find the matching subset of a SARIF file and output it or log it. | -| rebaseuri | Rebase the URIs in one or more sarif files. | +| page | Extract a subset of results from a source SARIF file | +| query | Find the matching subset of a SARIF file and output it or log it | +| rebaseuri | Rebase the URIs in one or more sarif files | | rewrite | Transform a SARIF file to a reformatted version | +| suppress | Suppress results from a SARIF file | | validate | Validate a SARIF File against the schema and against additional correctness rules. | | help | See Usage | | version | Display version information | @@ -60,6 +61,9 @@ Sarif.Multitool merge C:\Input\*.sarif --recurse --output-directory=C:\Output\ - : Extract new Results only from New Baseline Sarif.Multitool query NewBaseline.sarif --expression "BaselineState == 'New'" --output Current.NewResults.sarif +: Suppress Results +Sarif.Multitool suppress current.sarif --justification "some justification" --alias "some alias" --guids --timestamps --expiryInDays 5 --status Accepted --output suppressed.sarif + : Validate a SARIF file conforms to the schema Sarif.Multitool validate Other.sarif ``` diff --git a/src/ReleaseHistory.md b/src/ReleaseHistory.md index e6c83b8c4..1f2fcb75b 100644 --- a/src/ReleaseHistory.md +++ b/src/ReleaseHistory.md @@ -1,5 +1,12 @@ # SARIF Package Release History (SDK, Driver, Converters, and Multitool) +## Unreleased + +* FEATURE: `MultithreadCommandBase` will use cache when hashing is enabled. [#2388](https://github.com/microsoft/sarif-sdk/pull/2388) +* FEATURE: Flow suppressions when baselining. [#2390](https://github.com/microsoft/sarif-sdk/pull/2390) +* BUGFIX: Fix number of results when filing work item. [#2391](https://github.com/microsoft/sarif-sdk/pull/2391) +* FEATURE: Add `suppress` command to multitool. [#2394](https://github.com/microsoft/sarif-sdk/pull/2394) + ## **v2.4.11** [Sdk](https://www.nuget.org/packages/Sarif.Sdk/2.4.11) | [Driver](https://www.nuget.org/packages/Sarif.Driver/2.4.11) | [Converters](https://www.nuget.org/packages/Sarif.Converters/2.4.11) | [Multitool](https://www.nuget.org/packages/Sarif.Multitool/2.4.11) | [Multitool Library](https://www.nuget.org/packages/Sarif.Multitool.Library/2.4.11) * BUGFIX: Fix partitioning visitor log duplication. [#2369](https://github.com/microsoft/sarif-sdk/pull/2369) diff --git a/src/Sarif.Multitool.Library/OptionsInterpretter.cs b/src/Sarif.Multitool.Library/OptionsInterpretter.cs index 1e1205edc..131518095 100644 --- a/src/Sarif.Multitool.Library/OptionsInterpretter.cs +++ b/src/Sarif.Multitool.Library/OptionsInterpretter.cs @@ -14,7 +14,6 @@ public class OptionsInterpretter // Poor man's dependency injection public OptionsInterpretter() : this(new EnvironmentVariableGetter()) { - } public OptionsInterpretter(IEnvironmentVariableGetter environmentVariableGetter) @@ -24,7 +23,7 @@ public OptionsInterpretter(IEnvironmentVariableGetter environmentVariableGetter) private readonly IEnvironmentVariableGetter _environmentVariableGetter; - // Protected methods for abstract classes and public methods for concrete classes to ensure proper roll up and no + // Protected methods for abstract classes and public methods for concrete classes to ensure proper roll up and no // redundant execution. Only leaves of the class diagram should have public methods and be called outside this class protected void ConsumeEnvVarsAndInterpretOptions(CommonOptionsBase commonOptionsBase) { @@ -38,17 +37,17 @@ protected void ConsumeEnvVarsAndInterpretOptions(CommonOptionsBase commonOptions } #pragma warning disable IDE0060 // Ignore unused parameter for now + protected void ConsumeEnvVarsAndInterpretOptions(ExportConfigurationOptions exportConfigurationOptions) #pragma warning restore IDE0060 { - } #pragma warning disable IDE0060 // Ignore unused parameter for now + protected void ConsumeEnvVarsAndInterpretOptions(ExportRulesMetadataOptions exportConfigurationOptions) #pragma warning restore IDE0060 { - } protected void ConsumeEnvVarsAndInterpretOptions(AnalyzeOptionsBase analyzeOptionsBase) @@ -73,16 +72,23 @@ protected void ConsumeEnvVarsAndInterpretOptions(SingleFileOptionsBase singleFil ConsumeEnvVarsAndInterpretOptions((CommonOptionsBase)singleFileOptionsBase); } + public void ConsumeEnvVarsAndInterpretOptions(SuppressOptions options) + { + ConsumeEnvVarsAndInterpretOptions((SingleFileOptionsBase)options); + } + public void ConsumeEnvVarsAndInterpretOptions(AbsoluteUriOptions absoluteUriOptions) { ConsumeEnvVarsAndInterpretOptions((MultipleFilesOptionsBase)absoluteUriOptions); } #if DEBUG + public void ConsumeEnvVarsAndInterpretOptions(AnalyzeTestOptions analyzeTestOptions) { ConsumeEnvVarsAndInterpretOptions((AnalyzeOptionsBase)analyzeTestOptions); } + #endif public void ConsumeEnvVarsAndInterpretOptions(ApplyPolicyOptions applyPolicyOptions) @@ -121,17 +127,17 @@ public void ConsumeEnvVarsAndInterpretOptions(MergeOptions mergeOptions) } #pragma warning disable IDE0060 // Ignore unused parameter for now + public void ConsumeEnvVarsAndInterpretOptions(PageOptions pageOptions) #pragma warning restore IDE0060 { - } #pragma warning disable IDE0060 // Ignore unused parameter for now + public void ConsumeEnvVarsAndInterpretOptions(QueryOptions queryOptions) #pragma warning restore IDE0060 { - } public void ConsumeEnvVarsAndInterpretOptions(RebaseUriOptions rebaseUriOptions) diff --git a/src/Sarif.Multitool.Library/Sarif.Multitool.Library.csproj b/src/Sarif.Multitool.Library/Sarif.Multitool.Library.csproj index e45912ffe..e0439a0f1 100644 --- a/src/Sarif.Multitool.Library/Sarif.Multitool.Library.csproj +++ b/src/Sarif.Multitool.Library/Sarif.Multitool.Library.csproj @@ -62,4 +62,4 @@ - \ No newline at end of file + diff --git a/src/Sarif.Multitool.Library/SuppressCommand.cs b/src/Sarif.Multitool.Library/SuppressCommand.cs new file mode 100644 index 000000000..5cf1813fa --- /dev/null +++ b/src/Sarif.Multitool.Library/SuppressCommand.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Diagnostics; + +using Microsoft.CodeAnalysis.Sarif.Driver; +using Microsoft.CodeAnalysis.Sarif.Readers; +using Microsoft.CodeAnalysis.Sarif.Visitors; +using Microsoft.CodeAnalysis.Sarif.Writers; + +namespace Microsoft.CodeAnalysis.Sarif.Multitool +{ + public class SuppressCommand : CommandBase + { + public SuppressCommand(IFileSystem fileSystem = null) : base(fileSystem) + { + } + + public int Run(SuppressOptions options) + { + try + { + Console.WriteLine($"Suppress '{options.InputFilePath}' => '{options.OutputFilePath}'..."); + var w = Stopwatch.StartNew(); + + bool valid = ValidateOptions(options); + if (!valid) + { + return FAILURE; + } + + SarifLog currentSarifLog = PrereleaseCompatibilityTransformer.UpdateToCurrentVersion(FileSystem.FileReadAllText(options.InputFilePath), + options.Formatting, + out string _); + + SarifLog reformattedLog = new SuppressVisitor(options.Justification, + options.Alias, + options.Guids, + options.Timestamps, + options.ExpiryInDays, + options.Status).VisitSarifLog(currentSarifLog); + + string actualOutputPath = CommandUtilities.GetTransformedOutputFileName(options); + if (options.SarifOutputVersion == SarifVersion.OneZeroZero) + { + var visitor = new SarifCurrentToVersionOneVisitor(); + visitor.VisitSarifLog(reformattedLog); + + WriteSarifFile(FileSystem, visitor.SarifLogVersionOne, actualOutputPath, options.Formatting, SarifContractResolverVersionOne.Instance); + } + else + { + WriteSarifFile(FileSystem, reformattedLog, actualOutputPath, options.Formatting); + } + + w.Stop(); + Console.WriteLine($"Supress completed in {w.Elapsed}."); + } + catch (Exception ex) + { + Console.WriteLine(ex); + return FAILURE; + } + + return SUCCESS; + } + + private bool ValidateOptions(SuppressOptions options) + { + bool valid = true; + + valid &= options.Validate(); + valid &= options.ExpiryInDays >= 0; + valid &= !string.IsNullOrWhiteSpace(options.Justification); + valid &= (options.Status == SuppressionStatus.Accepted || options.Status == SuppressionStatus.UnderReview); + valid &= DriverUtilities.ReportWhetherOutputFileCanBeCreated(options.OutputFilePath, options.Force, FileSystem); + + return valid; + } + } +} diff --git a/src/Sarif.Multitool.Library/SuppressOptions.cs b/src/Sarif.Multitool.Library/SuppressOptions.cs new file mode 100644 index 000000000..ef2162763 --- /dev/null +++ b/src/Sarif.Multitool.Library/SuppressOptions.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using CommandLine; + +using Microsoft.CodeAnalysis.Sarif.Driver; + +namespace Microsoft.CodeAnalysis.Sarif.Multitool +{ + [Verb("suppress", HelpText = "Enrich a SARIF file with additional data.")] + public class SuppressOptions : SingleFileOptionsBase + { + [Option( + "justification", + HelpText = "A string that provides the rationale for the suppressions", + Required = true)] + public string Justification { get; set; } + + [Option( + "alias", + HelpText = "The account name associated with the suppression.")] + public string Alias { get; set; } + + [Option( + "guids", + HelpText = "A UUID that will be associated with a suppression.")] + public bool Guids { get; set; } + + [Option( + "timestamps", + HelpText = "The property 'timeUtc' that will be associated with a suppression.")] + public bool Timestamps { get; set; } + + [Option( + "expiryInDays", + HelpText = "The property 'expiryUtc' that will be associated with a suppression from the 'timeUtc'.")] + public int ExpiryInDays { get; set; } + + [Option( + "status", + HelpText = "The status that will be used in the suppression. Valid values include Accepted and UnderReview.")] + public SuppressionStatus Status { get; set; } + } +} diff --git a/src/Sarif.Multitool/Program.cs b/src/Sarif.Multitool/Program.cs index 3bb28586b..c820295bd 100644 --- a/src/Sarif.Multitool/Program.cs +++ b/src/Sarif.Multitool/Program.cs @@ -37,6 +37,7 @@ public static int Main(string[] args) QueryOptions, RebaseUriOptions, RewriteOptions, + SuppressOptions, ValidateOptions>(args) .WithParsed(x => { optionsInterpretter.ConsumeEnvVarsAndInterpretOptions(x); }) #if DEBUG @@ -53,6 +54,7 @@ public static int Main(string[] args) .WithParsed(x => { optionsInterpretter.ConsumeEnvVarsAndInterpretOptions(x); }) .WithParsed(x => { optionsInterpretter.ConsumeEnvVarsAndInterpretOptions(x); }) .WithParsed(x => { optionsInterpretter.ConsumeEnvVarsAndInterpretOptions(x); }) + .WithParsed(x => { optionsInterpretter.ConsumeEnvVarsAndInterpretOptions(x); }) .WithParsed(x => { optionsInterpretter.ConsumeEnvVarsAndInterpretOptions(x); }) .MapResult( (AbsoluteUriOptions absoluteUriOptions) => new AbsoluteUriCommand().Run(absoluteUriOptions), @@ -71,6 +73,7 @@ public static int Main(string[] args) (QueryOptions queryOptions) => new QueryCommand().Run(queryOptions), (RebaseUriOptions rebaseOptions) => new RebaseUriCommand().Run(rebaseOptions), (RewriteOptions rewriteOptions) => new RewriteCommand().Run(rewriteOptions), + (SuppressOptions options) => new SuppressCommand().Run(options), (ValidateOptions validateOptions) => new ValidateCommand().Run(validateOptions), _ => HandleParseError(args)); } diff --git a/src/Sarif/Visitors/SuppressVisitor.cs b/src/Sarif/Visitors/SuppressVisitor.cs new file mode 100644 index 000000000..1903e68f8 --- /dev/null +++ b/src/Sarif/Visitors/SuppressVisitor.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Microsoft.CodeAnalysis.Sarif.Visitors +{ + public class SuppressVisitor : SarifRewritingVisitor + { + private readonly bool guids; + private readonly string alias; + private readonly bool timestamps; + private readonly DateTime timeUtc; + private readonly DateTime expiryUtc; + private readonly int expiryInDays; + private readonly string justification; + private readonly SuppressionStatus suppressionStatus; + + public SuppressVisitor(string justification, + string alias, + bool guids, + bool timestamps, + int expiryInDays, + SuppressionStatus suppressionStatus) + { + this.alias = alias; + this.guids = guids; + this.timestamps = timestamps; + this.timeUtc = DateTime.UtcNow; + this.expiryInDays = expiryInDays; + this.justification = justification; + this.suppressionStatus = suppressionStatus; + this.expiryUtc = this.timeUtc.AddDays(expiryInDays); + } + + public override Result VisitResult(Result node) + { + if (node.Suppressions == null) + { + node.Suppressions = new List(); + } + + var suppression = new Suppression + { + Status = suppressionStatus, + Justification = justification, + Kind = SuppressionKind.External + }; + + if (!string.IsNullOrWhiteSpace(alias)) + { + suppression.SetProperty(nameof(alias), alias); + } + + if (guids) + { + suppression.SetProperty("guid", Guid.NewGuid()); + } + + if (timestamps) + { + suppression.SetProperty(nameof(timeUtc), timeUtc); + } + + if (expiryInDays > 0) + { + suppression.SetProperty(nameof(expiryUtc), expiryUtc); + } + + node.Suppressions.Add(suppression); + return base.VisitResult(node); + } + } +} diff --git a/src/Test.UnitTests.Sarif.Multitool.Library/SuppressCommandTests.cs b/src/Test.UnitTests.Sarif.Multitool.Library/SuppressCommandTests.cs new file mode 100644 index 000000000..1bd44ba1e --- /dev/null +++ b/src/Test.UnitTests.Sarif.Multitool.Library/SuppressCommandTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Text; + +using FluentAssertions; + +using Microsoft.CodeAnalysis.Sarif.Driver; + +using Moq; + +using Newtonsoft.Json; + +using Xunit; + +namespace Microsoft.CodeAnalysis.Sarif.Multitool +{ + public class SuppressCommandTests + { + [Fact] + public void SuppressCommand_ShouldReturnFailure_WhenBadArgumentsAreSupplied() + { + const string outputPath = @"c:\output.sarif"; + var optionsTestCases = new SuppressOptions[] + { + new SuppressOptions + { + ExpiryInDays = -1 + }, + new SuppressOptions + { + ExpiryInDays = 1, + Justification = string.Empty + }, + new SuppressOptions + { + ExpiryInDays = 1, + Justification = "some justification", + Status = SuppressionStatus.Rejected + }, + new SuppressOptions + { + ExpiryInDays = 1, + Justification = "some justification", + Status = SuppressionStatus.Accepted, + SarifOutputVersion = SarifVersion.Unknown + }, + new SuppressOptions + { + ExpiryInDays = -1, + Justification = "some justification", + OutputFilePath = outputPath, + Status = SuppressionStatus.Accepted + }, + }; + + var mock = new Mock(); + mock.Setup(f => f.FileExists(outputPath)) + .Returns(false); + + foreach (SuppressOptions options in optionsTestCases) + { + var command = new SuppressCommand(); + command.Run(options).Should().Be(CommandBase.FAILURE); + } + } + + [Fact] + public void SuppressCommand_ShouldReturnSuccess_WhenCorrectArgumentsAreSupplied() + { + var optionsTestCases = new SuppressOptions[] + { + new SuppressOptions + { + Alias = "some alias", + InputFilePath = @"C:\input.sarif", + OutputFilePath = @"C:\output.sarif", + Justification = "some justification", + Status = SuppressionStatus.Accepted + }, + new SuppressOptions + { + InputFilePath = @"C:\input.sarif", + OutputFilePath = @"C:\output.sarif", + Justification = "some justification", + Status = SuppressionStatus.UnderReview + }, + new SuppressOptions + { + Guids = true, + InputFilePath = @"C:\input.sarif", + OutputFilePath = @"C:\output.sarif", + Justification = "some justification", + Status = SuppressionStatus.Accepted + }, + new SuppressOptions + { + Guids = true, + ExpiryInDays = 5, + Timestamps = true, + InputFilePath = @"C:\input.sarif", + OutputFilePath = @"C:\output.sarif", + Justification = "some justification", + Status = SuppressionStatus.Accepted + }, + }; + + foreach (SuppressOptions options in optionsTestCases) + { + VerifySuppressCommand(options); + } + } + + private static void VerifySuppressCommand(SuppressOptions options) + { + var current = new SarifLog + { + Runs = new List + { + new Run + { + Results = new List + { + new Result + { + RuleId = "Test0001" + } + } + } + } + }; + + var transformedContents = new StringBuilder(); + var mockFileSystem = new Mock(); + mockFileSystem + .Setup(x => x.FileReadAllText(options.InputFilePath)) + .Returns(JsonConvert.SerializeObject(current)); + + mockFileSystem + .Setup(x => x.FileCreate(options.OutputFilePath)) + .Returns(() => new MemoryStreamToStringBuilder(transformedContents)); + + var command = new SuppressCommand(mockFileSystem.Object); + command.Run(options).Should().Be(CommandBase.SUCCESS); + + SarifLog suppressed = JsonConvert.DeserializeObject(transformedContents.ToString()); + suppressed.Runs[0].Results[0].Suppressions.Should().NotBeNullOrEmpty(); + + Suppression suppression = suppressed.Runs[0].Results[0].Suppressions[0]; + suppression.Status.Should().Be(options.Status); + suppression.Kind.Should().Be(SuppressionKind.External); + suppression.Justification.Should().Be(options.Justification); + + if (!string.IsNullOrWhiteSpace(options.Alias)) + { + suppression.GetProperty("alias").Should().Be(options.Alias); + } + + if (options.Guids && suppression.TryGetProperty("guid", out Guid guid)) + { + guid.Should().NotBeEmpty(); + } + + if (options.Timestamps && suppression.TryGetProperty("timeUtc", out DateTime timeUtc)) + { + timeUtc.Should().BeCloseTo(DateTime.UtcNow); + } + + if (options.ExpiryInDays > 0 && suppression.TryGetProperty("expiryUtc", out DateTime expiryUtc)) + { + expiryUtc.Should().BeCloseTo(DateTime.UtcNow.AddDays(options.ExpiryInDays)); + } + } + } +} diff --git a/src/Test.UnitTests.Sarif/Visitors/SuppressVisitorTests.cs b/src/Test.UnitTests.Sarif/Visitors/SuppressVisitorTests.cs new file mode 100644 index 000000000..11228baaf --- /dev/null +++ b/src/Test.UnitTests.Sarif/Visitors/SuppressVisitorTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +using FluentAssertions; + +using Microsoft.CodeAnalysis.Sarif.Visitors; + +using Xunit; + +namespace Microsoft.CodeAnalysis.Sarif.UnitTests.Visitors +{ + public class SuppressVisitorTests + { + [Fact] + public void SuppressVisitor_ShouldFlowPropertiesCorrectly() + { + var testCases = new[] + { + new + { + Alias = string.Empty, + Justification = "some suppress justification", + Guids = false, + Timestamps = false, + ExpiryInDays = 0, + SuppressionStatus = SuppressionStatus.Accepted + }, + new + { + Alias = "some alias", + Justification = "some suppress justification", + Guids = false, + Timestamps = false, + ExpiryInDays = 0, + SuppressionStatus = SuppressionStatus.Accepted + }, + new + { + Alias = "some alias", + Justification = "some suppress justification", + Guids = true, + Timestamps = false, + ExpiryInDays = 0, + SuppressionStatus = SuppressionStatus.Accepted + }, + new + { + Alias = "some alias", + Justification = "some suppress justification", + Guids = true, + Timestamps = true, + ExpiryInDays = 0, + SuppressionStatus = SuppressionStatus.Accepted + }, + new + { + Alias = "some alias", + Justification = "some suppress justification", + Guids = true, + Timestamps = true, + ExpiryInDays = 1, + SuppressionStatus = SuppressionStatus.Accepted + }, + new + { + Alias = "some alias", + Justification = "some suppress justification", + Guids = true, + Timestamps = true, + ExpiryInDays = 1, + SuppressionStatus = SuppressionStatus.UnderReview + }, + }; + + foreach (var testCase in testCases) + { + VerifySuppressVisitor(testCase.Alias, + testCase.Justification, + testCase.Guids, + testCase.Timestamps, + testCase.ExpiryInDays, + testCase.SuppressionStatus); + } + } + + private static void VerifySuppressVisitor(string alias, + string justification, + bool guids, + bool timestamps, + int expiryInDays, + SuppressionStatus suppressionStatus) + { + var visitor = new SuppressVisitor(justification, + alias, + guids, + timestamps, + expiryInDays, + suppressionStatus); + + var random = new Random(); + SarifLog current = RandomSarifLogGenerator.GenerateSarifLogWithRuns(random, runCount: 1, resultCount: 1); + SarifLog suppressed = visitor.VisitSarifLog(current); + IList results = suppressed.Runs[0].Results; + foreach (Result result in results) + { + result.Suppressions.Should().NotBeNullOrEmpty(); + + Suppression suppression = result.Suppressions[0]; + suppression.Status.Should().Be(suppressionStatus); + suppression.Justification.Should().Be(justification); + suppression.Kind.Should().Be(SuppressionKind.External); + + if (!string.IsNullOrWhiteSpace(alias)) + { + suppression.GetProperty("alias").Should().Be(alias); + } + + if (guids && suppression.TryGetProperty("guid", out Guid guid)) + { + guid.Should().NotBeEmpty(); + } + + if (timestamps && suppression.TryGetProperty("timeUtc", out DateTime timeUtc)) + { + timeUtc.Should().BeCloseTo(DateTime.UtcNow); + } + + if (expiryInDays > 0 && suppression.TryGetProperty("expiryUtc", out DateTime expiryUtc)) + { + expiryUtc.Should().BeCloseTo(DateTime.UtcNow.AddDays(expiryInDays)); + } + } + } + } +}