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,