diff --git a/.vscode/settings.json b/.vscode/settings.json index 954682c10d..a422e31f7e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -95,7 +95,9 @@ "deserializes", "Gitter", "hashtable", + "HEADREF", "Kubernetes", + "Multiformat", "Newtonsoft", "NOTCOUNT", "proxied", @@ -103,12 +105,14 @@ "PSRULE", "pwsh", "quickstart", + "REPOROOT", "runspace", "runspaces", "SARIF", "SBOM", "subselector", "unencrypted", + "Worktree", "xunit" ], "[csharp]": { diff --git a/docs/CHANGELOG-v3.md b/docs/CHANGELOG-v3.md index 384fa44c86..7eeb56a3a4 100644 --- a/docs/CHANGELOG-v3.md +++ b/docs/CHANGELOG-v3.md @@ -29,6 +29,11 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers What's changed since pre-release v3.0.0-B0137: +- General improvements: + - SARIF output has been improved to include effective configuration from a run by @BernieWhite. + [#1739](https://github.com/microsoft/PSRule/issues/1739) + - SARIF output has been improved to include file hashes for source files from a run by @BernieWhite. + [#1740](https://github.com/microsoft/PSRule/issues/1740) - Engineering: - Bump YamlDotNet to v15.1.0. [#1737](https://github.com/microsoft/PSRule/pull/1737) diff --git a/docs/analysis-output.md b/docs/analysis-output.md index b203deaa3e..c7a0337883 100644 --- a/docs/analysis-output.md +++ b/docs/analysis-output.md @@ -257,4 +257,11 @@ To add the scans tab to build results the [SARIF SAST Scans Tab][2] extension ne [2]: https://marketplace.visualstudio.com/items?itemName=sariftools.scans +### Verifying configuration + +:octicons-milestone-24: v3.0.0 + +The configuration used to run PSRule is included in properties of the run. +This can be used to verify the configuration used to run PSRule. + *[SARIF]: Static Analysis Results Interchange Format diff --git a/src/PSRule.Types/Options/ExecutionActionPreference.cs b/src/PSRule.Types/Options/ExecutionActionPreference.cs index d2e6cd0df8..25715af4ca 100644 --- a/src/PSRule.Types/Options/ExecutionActionPreference.cs +++ b/src/PSRule.Types/Options/ExecutionActionPreference.cs @@ -1,12 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Newtonsoft.Json.Converters; +using Newtonsoft.Json; + namespace PSRule.Options; /// /// Determines the action to take for execution behaviors. /// See for the specific behaviors that are configurable. /// +[JsonConverter(typeof(StringEnumConverter))] public enum ExecutionActionPreference { /// diff --git a/src/PSRule.Types/Options/LanguageMode.cs b/src/PSRule.Types/Options/LanguageMode.cs index 5996b0e73c..d06a6de32c 100644 --- a/src/PSRule.Types/Options/LanguageMode.cs +++ b/src/PSRule.Types/Options/LanguageMode.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Newtonsoft.Json.Converters; +using Newtonsoft.Json; + namespace PSRule.Options; /// @@ -8,6 +11,7 @@ namespace PSRule.Options; /// Does not affect YAML or JSON expressions. /// /// +[JsonConverter(typeof(StringEnumConverter))] public enum LanguageMode { /// diff --git a/src/PSRule.Types/Options/SessionState.cs b/src/PSRule.Types/Options/SessionState.cs index a96e27469f..a5b23de07d 100644 --- a/src/PSRule.Types/Options/SessionState.cs +++ b/src/PSRule.Types/Options/SessionState.cs @@ -1,11 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Newtonsoft.Json.Converters; +using Newtonsoft.Json; + namespace PSRule.Options; /// /// Configures how the initial PowerShell sandbox for executing rules is created. /// +[JsonConverter(typeof(StringEnumConverter))] public enum SessionState { /// diff --git a/src/PSRule/Common/GitHelper.cs b/src/PSRule/Common/GitHelper.cs index 8dacc73ae3..bbd7d98bf8 100644 --- a/src/PSRule/Common/GitHelper.cs +++ b/src/PSRule/Common/GitHelper.cs @@ -130,17 +130,17 @@ public static bool TryRepository(out string value, string path = null) } // Try .git/ - return false; + return TryGetOriginUrl(path, out value); } public static bool TryGetChangedFiles(string baseRef, string filter, string options, out string[] files) { // Get current tip - var source = TryRevision(out var source_sha) ? source_sha : "HEAD"; + var source = TryRevision(out var source_sha) ? source_sha : GIT_HEAD; var target = !string.IsNullOrEmpty(baseRef) ? baseRef : "HEAD^"; var bin = GetGitBinary(); - var args = GetGitArgs(target, source, filter, options); + var args = GetDiffArgs(target, source, filter, options); var tool = ExternalTool.Get(null, bin); files = Array.Empty(); @@ -157,7 +157,7 @@ private static string GetGitBinary() RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "git" : "git.exe"; } - private static string GetGitArgs(string target, string source, string filter, string options) + private static string GetDiffArgs(string target, string source, string filter, string options) { return $"diff --diff-filter={filter} --ignore-submodules=all --name-only --no-renames {target}"; } @@ -195,8 +195,42 @@ private static bool TryCommit(string path, out string value, out bool isRef) if (lines == null || lines.Length == 0) return false; - isRef = lines[0].StartsWith(GIT_REF_PREFIX, System.StringComparison.OrdinalIgnoreCase); + isRef = lines[0].StartsWith(GIT_REF_PREFIX, StringComparison.OrdinalIgnoreCase); value = isRef ? lines[0].Substring(5) : lines[0]; return true; } + + /// + /// Try to get the origin URL from the git config. + /// + private static bool TryGetOriginUrl(string path, out string value) + { + value = null; + + try + { + var bin = GetGitBinary(); + var args = GetWorktreeConfigArgs(); + var tool = ExternalTool.Get(null, bin); + + string[] lines = null; + if (!tool.WaitForExit(args, out var exitCode) || exitCode != 0) + return false; + + lines = tool.GetOutput().Split(new string[] { System.Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); + var origin = lines.Where(line => line.StartsWith("remote.origin.url=", StringComparison.OrdinalIgnoreCase)).FirstOrDefault()?.Split('=')?[1]; + value = origin; + } + catch + { + // Fail silently. + } + + return value != null; + } + + private static string GetWorktreeConfigArgs() + { + return "config --worktree --list"; + } } diff --git a/src/PSRule/Common/HashAlgorithmExtensions.cs b/src/PSRule/Common/HashAlgorithmExtensions.cs index 31a9e5125f..441c32961e 100644 --- a/src/PSRule/Common/HashAlgorithmExtensions.cs +++ b/src/PSRule/Common/HashAlgorithmExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Security.Cryptography; @@ -12,4 +12,22 @@ public static string GetDigest(this HashAlgorithm algorithm, byte[] buffer) var hash = algorithm.ComputeHash(buffer); return string.Join("", hash.Select(b => b.ToString("x2")).ToArray()); } + + public static string GetFileDigest(this HashAlgorithm algorithm, string path) + { + return algorithm.GetDigest(File.ReadAllBytes(path)); + } + + public static HashAlgorithm GetHashAlgorithm(this Options.HashAlgorithm algorithm) + { + if (algorithm == Options.HashAlgorithm.SHA256) + return SHA256.Create(); + + return algorithm == Options.HashAlgorithm.SHA384 ? SHA384.Create() : SHA512.Create(); + } + + public static string GetHashAlgorithmName(this Options.HashAlgorithm algorithm) + { + return algorithm == Options.HashAlgorithm.SHA256 ? "sha-256" : algorithm == Options.HashAlgorithm.SHA384 ? "sha-384" : "sha-512"; + } } diff --git a/src/PSRule/Common/JsonConverters.cs b/src/PSRule/Common/JsonConverters.cs index ff7fec4562..aeae1c946c 100644 --- a/src/PSRule/Common/JsonConverters.cs +++ b/src/PSRule/Common/JsonConverters.cs @@ -536,7 +536,7 @@ internal sealed class FieldMapJsonConverter : JsonConverter { public override bool CanRead => true; - public override bool CanWrite => false; + public override bool CanWrite => true; public override bool CanConvert(Type objectType) { @@ -552,7 +552,15 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { - throw new NotImplementedException(); + if (value is not FieldMap map) return; + + writer.WriteStartObject(); + foreach (var field in map) + { + writer.WritePropertyName(field.Key); + serializer.Serialize(writer, field.Value); + } + writer.WriteEndObject(); } private static void ReadFieldMap(FieldMap map, JsonReader reader) diff --git a/src/PSRule/Configuration/ConfigurationOption.cs b/src/PSRule/Configuration/ConfigurationOption.cs index d20f2f4f16..6936e8db47 100644 --- a/src/PSRule/Configuration/ConfigurationOption.cs +++ b/src/PSRule/Configuration/ConfigurationOption.cs @@ -42,7 +42,7 @@ public static implicit operator ConfigurationOption(Hashtable hashtable) } /// - /// Merge two option instances by repacing any unset properties from with values. + /// Merge two option instances by replacing any unset properties from with values. /// Values from that are set are not overridden. /// internal static ConfigurationOption Combine(ConfigurationOption o1, ConfigurationOption o2) diff --git a/src/PSRule/Configuration/FieldMap.cs b/src/PSRule/Configuration/FieldMap.cs index 0501720f92..9cfdab0f98 100644 --- a/src/PSRule/Configuration/FieldMap.cs +++ b/src/PSRule/Configuration/FieldMap.cs @@ -3,12 +3,14 @@ using System.Collections; using System.Dynamic; +using Newtonsoft.Json; namespace PSRule.Configuration; /// /// A mapping of fields to property names. /// +[JsonConverter(typeof(FieldMapJsonConverter))] public sealed class FieldMap : DynamicObject, IEnumerable> { private readonly Dictionary _Map; diff --git a/src/PSRule/Pipeline/Output/SarifBuilder.cs b/src/PSRule/Pipeline/Output/SarifBuilder.cs new file mode 100644 index 0000000000..29bb92438c --- /dev/null +++ b/src/PSRule/Pipeline/Output/SarifBuilder.cs @@ -0,0 +1,475 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis.Sarif; +using PSRule.Configuration; +using PSRule.Data; +using PSRule.Definitions.Rules; +using PSRule.Definitions; +using PSRule.Resources; +using PSRule.Rules; +using PSRule.Options; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json; +using System.Collections; + +namespace PSRule.Pipeline.Output; + +#nullable enable + +/// +/// A helper to build a SARIF log. +/// +internal sealed class SarifBuilder +{ + private const string TOOL_NAME = "PSRule"; + private const string TOOL_ORG = "Microsoft Corporation"; + private const string TOOL_GUID = "0130215d-58eb-4887-b6fa-31ed02500569"; + private const string RECOMMENDATION_MESSAGE_ID = "recommendation"; + private const string LOCATION_KIND_OBJECT = "object"; + private const string LOCATION_ID_REPOROOT = "REPO_ROOT"; + + private readonly Run _Run; + private readonly System.Security.Cryptography.HashAlgorithm _ConfiguredHashAlgorithm; + private readonly string _ConfiguredHashAlgorithmName; + private readonly System.Security.Cryptography.HashAlgorithm? _SHA265; + private readonly PSRuleOption _Option; + private readonly Dictionary _Rules; + private readonly Dictionary _Extensions; + private readonly Dictionary _Artifacts; + + public SarifBuilder(Source[] source, PSRuleOption option) + { + _Option = option; + _Rules = new Dictionary(); + _Extensions = new Dictionary(); + _Artifacts = new Dictionary(); + _Run = new Run + { + Tool = GetTool(source), + Results = new List(), + Invocations = GetInvocation(), + AutomationDetails = GetAutomationDetails(), + OriginalUriBaseIds = GetBaseIds(), + VersionControlProvenance = GetVersionControl(option.Repository), + }; + var algorithm = option.Execution.HashAlgorithm.GetValueOrDefault(ExecutionOption.Default.HashAlgorithm!.Value); + _ConfiguredHashAlgorithm = algorithm.GetHashAlgorithm(); + _ConfiguredHashAlgorithmName = algorithm.GetHashAlgorithmName(); + + // Always include SHA-256 to allow comparison with other tools and formats such as SPDX. + _SHA265 = algorithm != HashAlgorithm.SHA256 ? HashAlgorithm.SHA256.GetHashAlgorithm() : null; + } + + /// + /// Get information from version control system. + /// + /// + /// https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html#_Toc34317497 + /// + private static List GetVersionControl(RepositoryOption option) + { + var repository = option.Url; + return new List() + { + new() { + RepositoryUri = !string.IsNullOrEmpty(repository) ? new Uri(repository) : null, + RevisionId = !string.IsNullOrEmpty(repository) && GitHelper.TryRevision(out var revision) ? revision : null, + Branch = !string.IsNullOrEmpty(repository) && GitHelper.TryHeadBranch(out var branch) ? branch : null, + MappedTo = new ArtifactLocation + { + UriBaseId = LOCATION_ID_REPOROOT + }, + } + }; + } + + private static Dictionary GetBaseIds() + { + return new Dictionary(1) + { + { + LOCATION_ID_REPOROOT, + new ArtifactLocation + { + Description = GetMessage(ReportStrings.SARIF_REPOROOT_Description), + } + } + }; + } + + public SarifLog Build() + { + AddArtifacts(); + AddOptions(); + + var log = new SarifLog + { + Runs = new List(1), + }; + log.Runs.Add(_Run); + return log; + } + + public void Add(RuleRecord record) + { + if (record == null) + return; + + var rule = GetRule(record); + var result = new Result + { + RuleId = rule.Id, + Rule = rule, + Kind = GetKind(record), + Level = GetLevel(record), + Message = new Message { Text = record.Recommendation }, + Locations = GetLocations(record), + }; + + AddFields(result, record); + AddAnnotations(result, record); + AddArtifacts(record); + + // SARIF2004: Use the RuleId property instead of Rule for standalone rules. + if (rule.ToolComponent.Guid == TOOL_GUID) + { + result.RuleId = rule.Id; + result.Rule = null; + } + _Run.Results.Add(result); + } + + /// + /// Add non-null fields from the record to the result. + /// + private static void AddFields(Result result, RuleRecord record) + { + if (result == null || record?.Field == null || record.Field.Count == 0) return; + + // Filter out null values. + var fields = new Hashtable(); + foreach (DictionaryEntry kv in record.Field) + { + if (kv.Value != null) + fields[kv.Key] = kv.Value; + } + + if (fields.Count > 0) + result.SetProperty("fields", fields); + } + + /// + /// Add non-null annotations from the record to the result. + /// + private static void AddAnnotations(Result result, RuleRecord record) + { + if (result == null || record?.Info?.Annotations == null || record.Info.Annotations.Count == 0) return; + + // Filter out null values. + var annotations = new Hashtable(); + foreach (DictionaryEntry kv in record.Info.Annotations) + { + if (kv.Value != null && !string.Equals("online version", kv.Key.ToString(), StringComparison.OrdinalIgnoreCase)) + annotations[kv.Key] = kv.Value; + } + + if (annotations.Count > 0) + result.SetProperty("annotations", annotations); + } + + /// + /// Get collected artifacts. + /// + private void AddArtifacts() + { + _Run.Artifacts = _Artifacts.Values.OrderBy(item => item.Location.Index).ToList(); + } + + /// + /// Add options to the run. + /// + private void AddOptions() + { + var s = new JsonSerializer(); + s.Converters.Add(new PSObjectJsonConverter()); + s.NullValueHandling = NullValueHandling.Ignore; + + var localScope = JObject.FromObject(_Option, s); + var options = new JObject + { + ["workspace"] = localScope + }; + + _Run.SetProperty("options", options); + } + + private void AddArtifacts(RuleRecord record) + { + if (record.Source == null || record.Source.Length == 0) return; + + foreach (var source in record.Source) + AddArtifact(source); + } + + private void AddArtifact(TargetSourceInfo source) + { + if (source == null || string.IsNullOrEmpty(source.File)) return; + + var relativePath = source.GetPath(useRelativePath: true); + var fullPath = source.GetPath(useRelativePath: false); + if (relativePath == null || fullPath == null || _Artifacts.ContainsKey(relativePath)) return; + + var location = new ArtifactLocation + ( + uri: new Uri(relativePath, uriKind: UriKind.Relative), + uriBaseId: LOCATION_ID_REPOROOT, + index: _Artifacts.Count, + description: null, + properties: null + ); + var artifact = new Artifact + { + Location = location, + Hashes = GetArtifactHash(fullPath) + }; + + _Artifacts.Add(relativePath, artifact); + } + + private Dictionary? GetArtifactHash(string path) + { + if (!File.Exists(path)) return null; + + var hash = _ConfiguredHashAlgorithm.GetFileDigest(path); + var result = new Dictionary + { + [_ConfiguredHashAlgorithmName] = hash + }; + if (_SHA265 != null) + { + result["sha-256"] = _SHA265.GetFileDigest(path); + } + return result; + } + + private ReportingDescriptorReference GetRule(RuleRecord record) + { + var id = record.Ref ?? record.RuleId; + if (!_Rules.TryGetValue(id, out var descriptorReference)) + descriptorReference = AddRule(record, id); + + return descriptorReference; + } + + private ReportingDescriptorReference AddRule(RuleRecord record, string id) + { + if (string.IsNullOrEmpty(record.Info.ModuleName) || !_Extensions.TryGetValue(record.Info.ModuleName, out var toolComponent)) + toolComponent = _Run.Tool.Driver; + + // Add the rule to the component. + var descriptor = new ReportingDescriptor + { + Id = id, + Name = record.RuleName, + ShortDescription = GetMessageString(record.Info.Synopsis), + HelpUri = record.Info.GetOnlineHelpUri(), + FullDescription = GetMessageString(record.Info.Description), + MessageStrings = GetMessageStrings(record), + DefaultConfiguration = new ReportingConfiguration + { + Enabled = true, + Level = GetLevel(record), + } + }; + + toolComponent.Rules.Add(descriptor); + + // Create a reference to the rule. + var descriptorReference = new ReportingDescriptorReference + { + Id = descriptor.Id, + ToolComponent = new ToolComponentReference + { + Guid = toolComponent.Guid, + Name = toolComponent.Name, + Index = _Run.Tool.Extensions == null ? -1 : _Run.Tool.Extensions.IndexOf(toolComponent), + } + }; + _Rules.Add(id, descriptorReference); + return descriptorReference; + } + + private static RunAutomationDetails? GetAutomationDetails() + { + return PipelineContext.CurrentThread == null ? null : new RunAutomationDetails + { + Id = PipelineContext.CurrentThread.RunId, + }; + } + + private static List GetInvocation() + { + var result = new List(1); + var invocation = new Invocation + { + + }; + result.Add(invocation); + return result; + } + + private static Message GetMessage(string text) + { + return new Message + { + Text = text + }; + } + + private static MultiformatMessageString GetMessageString(InfoString text) + { + return new MultiformatMessageString + { + Text = text.Text + }; + } + + private static MultiformatMessageString GetMessageString(string text) + { + return new MultiformatMessageString + { + Text = text + }; + } + + private static Dictionary GetMessageStrings(RuleRecord record) + { + return new Dictionary(1) + { + { + RECOMMENDATION_MESSAGE_ID, + new MultiformatMessageString + { + Text = record.Recommendation + } + } + }; + } + + private static List? GetLocations(RuleRecord record) + { + if (!record.HasSource()) + return null; + + var result = new List(record.Source.Length); + for (var i = 0; i < record.Source.Length; i++) + { + result.Add(new Location + { + PhysicalLocation = GetPhysicalLocation(record.Source[i]), + LogicalLocation = new LogicalLocation + { + Name = record.TargetName, + FullyQualifiedName = string.Concat(record.TargetType, "/", record.TargetName), + Kind = LOCATION_KIND_OBJECT, + } + }); + } + return result; + } + + private static PhysicalLocation GetPhysicalLocation(TargetSourceInfo info) + { + var region = new Region + { + StartLine = info.Line ?? 1, + StartColumn = info.Position ?? 0, + }; + var location = new PhysicalLocation + { + ArtifactLocation = new ArtifactLocation + { + Uri = new Uri(info.GetPath(useRelativePath: true), UriKind.Relative), + UriBaseId = LOCATION_ID_REPOROOT, + }, + Region = region, + }; + return location; + } + + private static ResultKind GetKind(RuleRecord record) + { + if (record.Outcome == RuleOutcome.Pass) + return ResultKind.Pass; + + if (record.Outcome == RuleOutcome.Fail) + return ResultKind.Fail; + + return record.Outcome == RuleOutcome.Error || + record.Outcome == RuleOutcome.None && record.OutcomeReason == RuleOutcomeReason.Inconclusive ? + ResultKind.Open : + ResultKind.None; + } + + private static FailureLevel GetLevel(RuleRecord record) + { + if (record.Outcome != RuleOutcome.Fail) + return FailureLevel.None; + + if (record.Level == SeverityLevel.Error) + return FailureLevel.Error; + + return record.Level == SeverityLevel.Warning ? FailureLevel.Warning : FailureLevel.Note; + } + + private Tool GetTool(Source[] source) + { + var version = Engine.GetVersion(); + return new Tool + { + Driver = new ToolComponent + { + Name = TOOL_NAME, + SemanticVersion = version, + Organization = TOOL_ORG, + Guid = TOOL_GUID, + Rules = new List(), + InformationUri = new Uri("https://aka.ms/ps-rule", UriKind.Absolute), + }, + Extensions = GetExtensions(source), + }; + } + + private List? GetExtensions(Source[] source) + { + if (source == null || source.Length == 0) + return null; + + var result = new List(); + for (var i = 0; i < source.Length; i++) + { + if (source[i].Module != null && !_Extensions.ContainsKey(source[i].Module.Name)) + { + var extension = new ToolComponent + { + Name = source[i].Module.Name, + Version = source[i].Module.Version, + Guid = source[i].Module.Guid, + AssociatedComponent = new ToolComponentReference + { + Name = TOOL_NAME, + }, + InformationUri = new Uri(source[i].Module.ProjectUri, UriKind.Absolute), + Organization = source[i].Module.CompanyName, + Rules = new List(), + }; + _Extensions.Add(extension.Name, extension); + result.Add(extension); + } + } + return result.Count > 0 ? result : null; + } +} + +#nullable restore diff --git a/src/PSRule/Pipeline/Output/SarifOutputWriter.cs b/src/PSRule/Pipeline/Output/SarifOutputWriter.cs index 2740502680..c1316f0c37 100644 --- a/src/PSRule/Pipeline/Output/SarifOutputWriter.cs +++ b/src/PSRule/Pipeline/Output/SarifOutputWriter.cs @@ -2,333 +2,11 @@ // Licensed under the MIT License. using System.Text; -using Microsoft.CodeAnalysis.Sarif; using PSRule.Configuration; -using PSRule.Data; -using PSRule.Definitions; -using PSRule.Definitions.Rules; -using PSRule.Resources; using PSRule.Rules; namespace PSRule.Pipeline.Output; -internal sealed class SarifBuilder -{ - private const string TOOL_NAME = "PSRule"; - private const string TOOL_ORG = "Microsoft Corporation"; - private const string TOOL_GUID = "0130215d-58eb-4887-b6fa-31ed02500569"; - private const string RECOMMENDATION_MESSAGE_ID = "recommendation"; - private const string LOCATION_KIND_OBJECT = "object"; - private const string LOCATION_ID_REPOROOT = "REPOROOT"; - - private readonly Run _Run; - private readonly Dictionary _Rules; - private readonly Dictionary _Extensions; - - public SarifBuilder(Source[] source, PSRuleOption option) - { - _Rules = new Dictionary(); - _Extensions = new Dictionary(); - _Run = new Run - { - Tool = GetTool(source), - Results = new List(), - Invocations = GetInvocation(), - AutomationDetails = GetAutomationDetails(), - OriginalUriBaseIds = GetBaseIds(), - VersionControlProvenance = GetVersionControl(option.Repository), - }; - } - - /// - /// Get information from version control system. - /// - /// - /// https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html#_Toc34317497 - /// - private static IList GetVersionControl(RepositoryOption option) - { - var repository = option.Url; - return new List() - { - new() { - RepositoryUri = !string.IsNullOrEmpty(repository) ? new Uri(repository) : null, - RevisionId = !string.IsNullOrEmpty(repository) && GitHelper.TryRevision(out var revision) ? revision : null, - Branch = !string.IsNullOrEmpty(repository) && GitHelper.TryHeadBranch(out var branch) ? branch : null, - MappedTo = new ArtifactLocation - { - UriBaseId = LOCATION_ID_REPOROOT - }, - } - }; - } - - private static IDictionary GetBaseIds() - { - return new Dictionary(1) - { - { - LOCATION_ID_REPOROOT, - new ArtifactLocation - { - Description = GetMessage(ReportStrings.SARIF_REPOROOT_Description), - } - } - }; - } - - public SarifLog Build() - { - var log = new SarifLog - { - Runs = new List(1), - }; - log.Runs.Add(_Run); - return log; - } - - public void Add(RuleRecord record) - { - if (record == null) - return; - - var rule = GetRule(record); - var result = new Result - { - Rule = rule, - Kind = GetKind(record), - Level = GetLevel(record), - Message = new Message { Text = record.Recommendation }, - Locations = GetLocations(record), - }; - - // SARIF2004: Use the RuleId property instead of Rule for standalone rules. - if (rule.ToolComponent.Guid == TOOL_GUID) - { - result.RuleId = rule.Id; - result.Rule = null; - } - _Run.Results.Add(result); - } - - private ReportingDescriptorReference GetRule(RuleRecord record) - { - var id = record.Ref ?? record.RuleId; - if (!_Rules.TryGetValue(id, out var descriptorReference)) - descriptorReference = AddRule(record, id); - - return descriptorReference; - } - - private ReportingDescriptorReference AddRule(RuleRecord record, string id) - { - if (string.IsNullOrEmpty(record.Info.ModuleName) || !_Extensions.TryGetValue(record.Info.ModuleName, out var toolComponent)) - toolComponent = _Run.Tool.Driver; - - // Add the rule to the component - var descriptor = new ReportingDescriptor - { - Id = id, - Name = record.RuleName, - ShortDescription = GetMessageString(record.Info.Synopsis), - HelpUri = record.Info.GetOnlineHelpUri(), - FullDescription = GetMessageString(record.Info.Description), - MessageStrings = GetMessageStrings(record), - DefaultConfiguration = new ReportingConfiguration - { - Enabled = true, - Level = GetLevel(record), - } - }; - toolComponent.Rules.Add(descriptor); - - // Create a reference to the rule - var descriptorReference = new ReportingDescriptorReference - { - Id = descriptor.Id, - ToolComponent = new ToolComponentReference - { - Guid = toolComponent.Guid, - Name = toolComponent.Name, - Index = _Run.Tool.Extensions == null ? -1 : _Run.Tool.Extensions.IndexOf(toolComponent), - } - }; - _Rules.Add(id, descriptorReference); - return descriptorReference; - } - - private static RunAutomationDetails GetAutomationDetails() - { - return PipelineContext.CurrentThread == null ? null : new RunAutomationDetails - { - Id = PipelineContext.CurrentThread.RunId, - }; - } - - private static IList GetInvocation() - { - var result = new List(1); - var invocation = new Invocation - { - - }; - result.Add(invocation); - return result; - } - - private static Message GetMessage(string text) - { - return new Message - { - Text = text - }; - } - - private static MultiformatMessageString GetMessageString(InfoString text) - { - return new MultiformatMessageString - { - Text = text.Text - }; - } - - private static MultiformatMessageString GetMessageString(string text) - { - return new MultiformatMessageString - { - Text = text - }; - } - - private static IDictionary GetMessageStrings(RuleRecord record) - { - return new Dictionary(1) - { - { - RECOMMENDATION_MESSAGE_ID, - new MultiformatMessageString - { - Text = record.Recommendation - } - } - }; - } - - private static IList GetLocations(RuleRecord record) - { - if (!record.HasSource()) - return null; - - var result = new List(record.Source.Length); - for (var i = 0; i < record.Source.Length; i++) - { - result.Add(new Location - { - PhysicalLocation = GetPhysicalLocation(record.Source[i]), - LogicalLocation = new LogicalLocation - { - Name = record.TargetName, - FullyQualifiedName = string.Concat(record.TargetType, "/", record.TargetName), - Kind = LOCATION_KIND_OBJECT, - } - }); - } - return result; - } - - private static PhysicalLocation GetPhysicalLocation(TargetSourceInfo info) - { - var region = new Region - { - StartLine = info.Line ?? 1, - StartColumn = info.Position ?? 0, - }; - var location = new PhysicalLocation - { - ArtifactLocation = new ArtifactLocation - { - Uri = new Uri(info.GetPath(useRelativePath: true), UriKind.Relative), - UriBaseId = LOCATION_ID_REPOROOT, - }, - Region = region, - }; - return location; - } - - private static ResultKind GetKind(RuleRecord record) - { - if (record.Outcome == RuleOutcome.Pass) - return ResultKind.Pass; - - if (record.Outcome == RuleOutcome.Fail) - return ResultKind.Fail; - - return record.Outcome == RuleOutcome.Error || - record.Outcome == RuleOutcome.None && record.OutcomeReason == RuleOutcomeReason.Inconclusive ? - ResultKind.Open : - ResultKind.None; - } - - private static FailureLevel GetLevel(RuleRecord record) - { - if (record.Outcome != RuleOutcome.Fail) - return FailureLevel.None; - - if (record.Level == SeverityLevel.Error) - return FailureLevel.Error; - - return record.Level == SeverityLevel.Warning ? FailureLevel.Warning : FailureLevel.Note; - } - - private Tool GetTool(Source[] source) - { - var version = Engine.GetVersion(); - return new Tool - { - Driver = new ToolComponent - { - Name = TOOL_NAME, - SemanticVersion = version, - Organization = TOOL_ORG, - Guid = TOOL_GUID, - Rules = new List(), - InformationUri = new Uri("https://aka.ms/ps-rule", UriKind.Absolute), - }, - Extensions = GetExtensions(source), - }; - } - - private IList GetExtensions(Source[] source) - { - if (source == null || source.Length == 0) - return null; - - var result = new List(); - for (var i = 0; i < source.Length; i++) - { - if (source[i].Module != null && !_Extensions.ContainsKey(source[i].Module.Name)) - { - var extension = new ToolComponent - { - Name = source[i].Module.Name, - Version = source[i].Module.Version, - Guid = source[i].Module.Guid, - AssociatedComponent = new ToolComponentReference - { - Name = TOOL_NAME, - }, - InformationUri = new Uri(source[i].Module.ProjectUri, UriKind.Absolute), - Organization = source[i].Module.CompanyName, - Rules = new List(), - }; - _Extensions.Add(extension.Name, extension); - result.Add(extension); - } - } - return result.Count > 0 ? result : null; - } -} - internal sealed class SarifOutputWriter : SerializationOutputWriter { private readonly SarifBuilder _Builder; diff --git a/src/PSRule/Pipeline/PipelineContext.cs b/src/PSRule/Pipeline/PipelineContext.cs index c601b58552..5d594b9ca9 100644 --- a/src/PSRule/Pipeline/PipelineContext.cs +++ b/src/PSRule/Pipeline/PipelineContext.cs @@ -84,7 +84,7 @@ private PipelineContext(PSRuleOption option, IHostContext hostContext, PipelineI _Unresolved = unresolved ?? new List(); _TrackedIssues = new List(); - ObjectHashAlgorithm = GetHashAlgorithm(option.Execution.HashAlgorithm.GetValueOrDefault(ExecutionOption.Default.HashAlgorithm.Value)); + ObjectHashAlgorithm = option.Execution.HashAlgorithm.GetValueOrDefault(ExecutionOption.Default.HashAlgorithm.Value).GetHashAlgorithm(); RunId = Environment.GetRunId() ?? ObjectHashAlgorithm.GetDigest(Guid.NewGuid().ToByteArray()); RunTime = Stopwatch.StartNew(); _DefaultOptionContext = _OptionBuilder?.Build(null); @@ -291,14 +291,6 @@ private void ReportIssue(RunspaceContext runspaceContext) // runspaceContext.WarnMissingApiVersion(_TrackedIssues[i].Kind, _TrackedIssues[i].Id); } - private static System.Security.Cryptography.HashAlgorithm GetHashAlgorithm(Options.HashAlgorithm hashAlgorithm) - { - if (hashAlgorithm == Options.HashAlgorithm.SHA256) - return SHA256.Create(); - - return hashAlgorithm == Options.HashAlgorithm.SHA384 ? SHA384.Create() : SHA512.Create(); - } - #region IBindingContext public bool GetPathExpression(string path, out PathExpression expression) diff --git a/tests/PSRule.Tests/OutputWriterTests.cs b/tests/PSRule.Tests/OutputWriterTests.cs index 7cea4a0ea2..db1c57b0b5 100644 --- a/tests/PSRule.Tests/OutputWriterTests.cs +++ b/tests/PSRule.Tests/OutputWriterTests.cs @@ -38,25 +38,31 @@ public void Sarif() var actual = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()); Assert.NotNull(actual); - Assert.Equal("PSRule", actual["runs"][0]["tool"]["driver"]["name"]); + Assert.Equal("PSRule", actual["runs"][0]["tool"]["driver"]["name"].Value()); Assert.Equal("0.0.1", actual["runs"][0]["tool"]["driver"]["semanticVersion"].Value().Split('+')[0]); Assert.Equal("https://github.com/microsoft/PSRule.UnitTest", actual["runs"][0]["versionControlProvenance"][0]["repositoryUri"].Value()); // Pass - Assert.Equal("TestModule\\rule-001", actual["runs"][0]["results"][0]["ruleId"]); - Assert.Equal("none", actual["runs"][0]["results"][0]["level"]); + Assert.Equal("TestModule\\rule-001", actual["runs"][0]["results"][0]["ruleId"].Value()); + Assert.Equal("none", actual["runs"][0]["results"][0]["level"].Value()); // Fail with error - Assert.Equal("rid-002", actual["runs"][0]["results"][1]["ruleId"]); - Assert.Equal("error", actual["runs"][0]["results"][1]["level"]); + Assert.Equal("rid-002", actual["runs"][0]["results"][1]["ruleId"].Value()); + Assert.Equal("error", actual["runs"][0]["results"][1]["level"].Value()); + Assert.Equal("Custom annotation", actual["runs"][0]["results"][1]["properties"]["annotations"]["annotation-data"].Value()); + Assert.Equal("Custom field data", actual["runs"][0]["results"][1]["properties"]["fields"]["field-data"].Value()); // Fail with warning - Assert.Equal("rid-003", actual["runs"][0]["results"][2]["ruleId"]); + Assert.Equal("rid-003", actual["runs"][0]["results"][2]["ruleId"].Value()); Assert.Null(actual["runs"][0]["results"][2]["level"]); // Fail with note - Assert.Equal("rid-004", actual["runs"][0]["results"][3]["ruleId"]); - Assert.Equal("note", actual["runs"][0]["results"][3]["level"]); + Assert.Equal("rid-004", actual["runs"][0]["results"][3]["ruleId"].Value()); + Assert.Equal("note", actual["runs"][0]["results"][3]["level"].Value()); + + // Check options + Assert.Equal(option.Repository.Url, actual["runs"][0]["properties"]["options"]["workspace"]["Repository"]["Url"].Value()); + Assert.False(actual["runs"][0]["properties"]["options"]["workspace"]["Output"]["SarifProblemsOnly"].Value()); } [Fact] @@ -76,20 +82,20 @@ public void SarifProblemsOnly() var actual = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()); Assert.NotNull(actual); - Assert.Equal("PSRule", actual["runs"][0]["tool"]["driver"]["name"]); + Assert.Equal("PSRule", actual["runs"][0]["tool"]["driver"]["name"].Value()); Assert.Equal("0.0.1", actual["runs"][0]["tool"]["driver"]["semanticVersion"].Value().Split('+')[0]); // Fail with error - Assert.Equal("rid-002", actual["runs"][0]["results"][0]["ruleId"]); - Assert.Equal("error", actual["runs"][0]["results"][0]["level"]); + Assert.Equal("rid-002", actual["runs"][0]["results"][0]["ruleId"].Value()); + Assert.Equal("error", actual["runs"][0]["results"][0]["level"].Value()); // Fail with warning - Assert.Equal("rid-003", actual["runs"][0]["results"][1]["ruleId"]); + Assert.Equal("rid-003", actual["runs"][0]["results"][1]["ruleId"].Value()); Assert.Null(actual["runs"][0]["results"][1]["level"]); // Fail with note - Assert.Equal("rid-004", actual["runs"][0]["results"][2]["ruleId"]); - Assert.Equal("note", actual["runs"][0]["results"][2]["level"]); + Assert.Equal("rid-004", actual["runs"][0]["results"][2]["ruleId"].Value()); + Assert.Equal("note", actual["runs"][0]["results"][2]["level"].Value()); } [Fact] @@ -125,7 +131,11 @@ public void Yaml() time: 500 - detail: reason: [] + field: + field-data: Custom field data info: + annotations: + annotation-data: Custom annotation moduleName: TestModule recommendation: Recommendation for rule 002 level: Error @@ -141,7 +151,11 @@ public void Yaml() time: 1000 - detail: reason: [] + field: + field-data: Custom field data info: + annotations: + annotation-data: Custom annotation moduleName: TestModule recommendation: Recommendation for rule 002 level: Warning @@ -157,7 +171,11 @@ public void Yaml() time: 1000 - detail: reason: [] + field: + field-data: Custom field data info: + annotations: + annotation-data: Custom annotation moduleName: TestModule recommendation: Recommendation for rule 002 level: Information @@ -214,7 +232,13 @@ public void Json() }, { ""detail"": {}, + ""field"": { + ""field-data"": ""Custom field data"" + }, ""info"": { + ""annotations"": { + ""annotation-data"": ""Custom annotation"" + }, ""displayName"": ""Rule 002"", ""moduleName"": ""TestModule"", ""name"": ""rule-002"", @@ -235,7 +259,13 @@ public void Json() }, { ""detail"": {}, + ""field"": { + ""field-data"": ""Custom field data"" + }, ""info"": { + ""annotations"": { + ""annotation-data"": ""Custom annotation"" + }, ""displayName"": ""Rule 002"", ""moduleName"": ""TestModule"", ""name"": ""rule-002"", @@ -256,7 +286,13 @@ public void Json() }, { ""detail"": {}, + ""field"": { + ""field-data"": ""Custom field data"" + }, ""info"": { + ""annotations"": { + ""annotation-data"": ""Custom annotation"" + }, ""displayName"": ""Rule 002"", ""moduleName"": ""TestModule"", ""name"": ""rule-002"", @@ -359,6 +395,17 @@ private static RuleRecord GetPass() private static RuleRecord GetFail(string ruleRef = "rid-002", SeverityLevel level = SeverityLevel.Error, string synopsis = "This is rule 002.", string ruleId = "TestModule\\rule-002") { + var info = new RuleHelpInfo( + "rule-002", + "Rule 002", + "TestModule", + synopsis: new InfoString(synopsis), + recommendation: new InfoString("Recommendation for rule 002") + ); + info.Annotations = new Hashtable + { + ["annotation-data"] = "Custom annotation" + }; return new RuleRecord( runId: "run-001", ruleId: ResourceId.Parse(ruleId), @@ -367,14 +414,11 @@ private static RuleRecord GetFail(string ruleRef = "rid-002", SeverityLevel leve targetName: "TestObject1", targetType: "TestType", tag: new ResourceTags(), - info: new RuleHelpInfo( - "rule-002", - "Rule 002", - "TestModule", - synopsis: new InfoString(synopsis), - recommendation: new InfoString("Recommendation for rule 002") - ), - field: new Hashtable(), + info: info, + field: new Hashtable + { + ["field-data"] = "Custom field data" + }, level: level, extent: null, outcome: RuleOutcome.Fail,