diff --git a/README.md b/README.md index 304ee9514c..8f9033d7de 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,7 @@ The following conceptual topics exist in the `PSRule` module: - [Output.Path](https://aka.ms/ps-rule/options#outputpath) - [Output.SarifProblemsOnly](https://aka.ms/ps-rule/options#outputsarifproblemsonly) - [Output.Style](https://aka.ms/ps-rule/options#outputstyle) + - [Override.Level](https://aka.ms/ps-rule/options#overridelevel) - [Repository.BaseRef](https://aka.ms/ps-rule/options#repositorybaseref) - [Repository.Url](https://aka.ms/ps-rule/options#repositoryurl) - [Requires](https://aka.ms/ps-rule/options#requires) diff --git a/docs/CHANGELOG-v3.md b/docs/CHANGELOG-v3.md index 9b5d5aef3b..f2ee238fb1 100644 --- a/docs/CHANGELOG-v3.md +++ b/docs/CHANGELOG-v3.md @@ -27,6 +27,12 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers ## Unreleased +- New features: + - Added support for overriding rule severity level by @BernieWhite. + [#1180](https://github.com/microsoft/PSRule/issues/1180) + - Baselines now accept a new `spec.overrides.level` property which configures severity level overrides. + - Options now accept a new `overrides.level` properties which configures severity level overrides. + - For example, a rule that generates an `Error` can be overridden to `Warning`. - General improvements: - Automatically restore missing modules when running CLI by @BernieWhite. [#2552](https://github.com/microsoft/PSRule/issues/2552) diff --git a/docs/concepts/PSRule/en-US/about_PSRule_Baseline.md b/docs/concepts/PSRule/en-US/about_PSRule_Baseline.md index 266a6dc8d5..b5d5f5266e 100644 --- a/docs/concepts/PSRule/en-US/about_PSRule_Baseline.md +++ b/docs/concepts/PSRule/en-US/about_PSRule_Baseline.md @@ -14,6 +14,7 @@ A baseline includes a set of rule and configuration options that are used for ev The following baseline options can be configured: - [Configuration](about_PSRule_Options.md#configuration) +- [Override.Level](about_PSRule_Options.md#overridelevel) - [Rule.Include](about_PSRule_Options.md#ruleinclude) - [Rule.IncludeLocal](about_PSRule_Options.md#ruleincludelocal) - [Rule.Exclude](about_PSRule_Options.md#ruleexclude) @@ -48,8 +49,9 @@ metadata: annotations: { } spec: # One or more baseline options - rule: { } configuration: { } + override: {} + rule: { } ``` For example: @@ -100,8 +102,9 @@ To define a JSON baseline spec use the following structure: "annotations": {} }, "spec": { + "configuration": {}, + "override": {}, "rule": {}, - "configuration": {} } } ] diff --git a/docs/concepts/PSRule/en-US/about_PSRule_Options.md b/docs/concepts/PSRule/en-US/about_PSRule_Options.md index 0aabf41c42..3a1284419c 100644 --- a/docs/concepts/PSRule/en-US/about_PSRule_Options.md +++ b/docs/concepts/PSRule/en-US/about_PSRule_Options.md @@ -68,6 +68,7 @@ The following workspace options are available for use: Additionally the following baseline options can be included: - [Configuration](#configuration) +- [Override.Level](#overridelevel) - [Rule.Baseline](#rulebaseline) - [Rule.Include](#ruleinclude) - [Rule.IncludeLocal](#ruleincludelocal) @@ -3012,6 +3013,52 @@ variables: value: 2 ``` +### Override.Level + +This option is used to override the severity level of one or more rules. +When specified, the severity level of the rule will be set to the value specified. +Use this option to change the severity level of a rule to be different then originally defined by the author. + +The following severity levels are available: + +- `Error` - A serious problem that must be addressed before going forward. +- `Warning` - A problem that should be addressed. +- `Information` - A minor problem or an opportunity to improve the code. + +This option can be specified using: + +```powershell +# PowerShell: Using the OverrideLevel parameter +$option = New-PSRuleOption -OverrideLevel @{ 'rule1' = 'Information' }; +``` + +```powershell +# PowerShell: Using the OVerride.Level hashtable key +$option = New-PSRuleOption -Option @{ 'Override.Level.rule1' = 'Information' }; +``` + +```powershell +# PowerShell: Using the OverrideLevel parameter to set YAML +Set-PSRuleOption -OverrideLevel @{ 'rule1' = 'Information' }; +``` + +```yaml +# YAML: Using the override/level property +override: + level: + rule1: Information +``` + +```bash +# Bash: Using environment variable +export PSRULE_OVERRIDE_LEVEL_RULE1='Information' +``` + +```powershell +# PowerShell: Using environment variable +$env:PSRULE_OVERRIDE_LEVEL_RULE1 = 'Information'; +``` + ### Repository.BaseRef This option is used for specify the base branch for pull requests. @@ -3464,6 +3511,12 @@ output: sarifProblemsOnly: false style: GitHubActions +# Overrides the severity level for rules +override: + level: + Rule1: Error + Rule2: Warning + # Configure rule suppression suppression: storageAccounts.UseHttps: @@ -3571,6 +3624,9 @@ output: sarifProblemsOnly: true style: Detect +override: + level: { } + # Configure rule suppression suppression: { } diff --git a/docs/concepts/options.md b/docs/concepts/options.md new file mode 100644 index 0000000000..b74bb49e89 --- /dev/null +++ b/docs/concepts/options.md @@ -0,0 +1,26 @@ +# Options + +Options are used to customize how rules are evaluated and the resulting output. +You can set options in multiple ways, including: + +- Parameters +- Environment variables +- Configuration files + +Rules or modules could also have a defaults configured by the rule or module author. + +## Option precedence + +When setting options, you may have a situation where an option is set to different values. +For example, you may set an option in a configuration file and also set the same option as a parameter. + +When this happens, PSRule will use the option with the highest precedence. + +Option precedence is as follows: + +1. Parameters +2. Explicit baselines +3. Environment variables +4. Configuration files +5. Default baseline +6. Module defaults diff --git a/docs/concepts/sarif-format.md b/docs/concepts/sarif-format.md new file mode 100644 index 0000000000..df39912577 --- /dev/null +++ b/docs/concepts/sarif-format.md @@ -0,0 +1,19 @@ +# SARIF Output + +PSRule uses a JSON structured output format called the + +SARIF format to report results. +The SARIF format is a standard format for the output of static analysis tools. +The format is designed to be easily consumed by other tools and services. + +## Runs + +When running PSRule executed a run will be generated in `runs` containing details about PSRule and configuration details. + +## Invocation + +The `invocation` property reports runtime information about how the run started. + +### RuleConfigurationOverrides + +When a rule has been overridden in configuration this invocation property will contain any level overrides. diff --git a/schemas/PSRule-language.schema.json b/schemas/PSRule-language.schema.json index bbc416d993..8c12b6fba6 100644 --- a/schemas/PSRule-language.schema.json +++ b/schemas/PSRule-language.schema.json @@ -209,6 +209,39 @@ } }, "additionalProperties": false + }, + "override": { + "type": "object", + "title": "Overrides", + "description": "Specifies additional rule overrides.", + "properties": { + "level": { + "type": "object", + "title": "Override severity level", + "description": "Overrides the severity level defined by the rule to the level specified. A rule can be configured with one of the following: Error, Warning, Information.", + "markdownDescription": "Overrides the severity level defined by the rule to the level specified.\n\nA rule can be configured with one of the following: `Error`, `Warning`, `Information`.\n\n[See help](https://microsoft.github.io/PSRule/v3/concepts/PSRule/en-US/about_PSRule_Options/#overridelevel)", + "additionalProperties": { + "type": "string", + "title": "Override level by rule", + "description": "Specify the new severity level of the rule. Choose one of the following supported values: Error, Warning, Information.", + "markdownDescription": "Specify the new severity level of the rule.\n\nChoose one of the following supported values: `Error`, `Warning`, `Information`.\n\n[See help](https://microsoft.github.io/PSRule/v3/concepts/PSRule/en-US/about_PSRule_Options/#overridelevel)", + "enum": [ + "Error", + "Warning", + "Information" + ] + }, + "defaultSnippets": [ + { + "label": "Override level", + "body": { + "${1:Rule}": "Error" + } + } + ] + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/schemas/PSRule-options.schema.json b/schemas/PSRule-options.schema.json index 1d93d78e5b..3cfabb99a3 100644 --- a/schemas/PSRule-options.schema.json +++ b/schemas/PSRule-options.schema.json @@ -928,6 +928,39 @@ }, "additionalProperties": false }, + "override-option": { + "type": "object", + "title": "Overrides", + "description": "Specifies additional rule overrides.", + "properties": { + "level": { + "type": "object", + "title": "Override severity level", + "description": "Overrides the severity level defined by the rule to the level specified. A rule can be configured with one of the following: Error, Warning, Information.", + "markdownDescription": "Overrides the severity level defined by the rule to the level specified.\n\nA rule can be configured with one of the following: `Error`, `Warning`, `Information`.\n\n[See help](https://microsoft.github.io/PSRule/v3/concepts/PSRule/en-US/about_PSRule_Options/#overridelevel)", + "additionalProperties": { + "type": "string", + "title": "Override level by rule", + "description": "Specify the new severity level of the rule. Choose one of the following supported values: Error, Warning, Information.", + "markdownDescription": "Specify the new severity level of the rule.\n\nChoose one of the following supported values: `Error`, `Warning`, `Information`.\n\n[See help](https://microsoft.github.io/PSRule/v3/concepts/PSRule/en-US/about_PSRule_Options/#overridelevel)", + "enum": [ + "Error", + "Warning", + "Information" + ] + }, + "defaultSnippets": [ + { + "label": "Override level", + "body": { + "${1:Rule}": "Error" + } + } + ] + } + }, + "additionalProperties": false + }, "options": { "properties": { "baseline": { @@ -982,6 +1015,10 @@ "type": "object", "$ref": "#/definitions/output-option" }, + "override": { + "type": "object", + "$ref": "#/definitions/override-option" + }, "repository": { "type": "object", "$ref": "#/definitions/repository-option" diff --git a/src/PSRule.Types/Converters/TypeConverter.cs b/src/PSRule.Types/Converters/TypeConverter.cs index 91c9c679d4..f88f044043 100644 --- a/src/PSRule.Types/Converters/TypeConverter.cs +++ b/src/PSRule.Types/Converters/TypeConverter.cs @@ -265,6 +265,20 @@ public static bool TryDouble(object o, bool convert, out double value) return false; } + /// + /// Try to get the environment variable as a enum of type . + /// + public static bool TryEnum(object o, bool convert, out T? value) where T : struct, Enum + { + if (o is T t || convert && o is string s && Enum.TryParse(s, ignoreCase: true, out t)) + { + value = t; + return true; + } + value = null; + return false; + } + private static bool TryGetValue(object o, string propertyName, out object? value) { value = null; diff --git a/src/PSRule.Types/Data/EnumMap.cs b/src/PSRule.Types/Data/EnumMap.cs new file mode 100644 index 0000000000..0dcf0af149 --- /dev/null +++ b/src/PSRule.Types/Data/EnumMap.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; +using PSRule.Converters; + +namespace PSRule.Data; + +/// +/// A mapping of string to string arrays. +/// +public sealed class EnumMap : KeyMap where T : struct, Enum +{ + /// + /// Create an empty instance. + /// + public EnumMap() + : base() { } + + /// + /// Create an instance by copying an existing . + /// + internal EnumMap(EnumMap map) + : base(map) { } + + /// + /// Create an instance by copying mapped keys from a string dictionary. + /// + internal EnumMap(IDictionary map) + : base(map) { } + + /// + /// Create an instance by copying mapped keys from a . + /// + /// + internal EnumMap(Hashtable map) + : base(map) { } + + /// + /// + /// + /// + public static implicit operator EnumMap(Hashtable hashtable) + { + return new EnumMap(hashtable); + } + + /// + /// Convert a hashtable into a instance. + /// + public static EnumMap FromHashtable(Hashtable hashtable) + { + return new EnumMap(hashtable); + } + + /// + /// + /// + /// + /// + /// + protected override bool TryConvertValue(object o, out T value) + { + value = default; + if (TypeConverter.TryEnum(o, convert: true, out var result) && result != null) + { + value = result.Value; + return true; + } + return false; + } +} diff --git a/src/PSRule.Types/Data/KeyMap.cs b/src/PSRule.Types/Data/KeyMap.cs new file mode 100644 index 0000000000..63f2fff546 --- /dev/null +++ b/src/PSRule.Types/Data/KeyMap.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections; + +namespace PSRule.Data; + +/// +/// A mapping of string to string arrays. +/// +public abstract class KeyMap : IEnumerable> +{ + private readonly Dictionary _Map; + + /// + /// Create an empty instance. + /// + protected KeyMap() + { + _Map = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Create an instance by copying an existing . + /// + protected KeyMap(KeyMap map) + { + _Map = new Dictionary(map._Map, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Create an instance by copying mapped keys from a string dictionary. + /// + protected KeyMap(IDictionary map) + { + _Map = new Dictionary(map, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Create an instance by copying mapped keys from a . + /// + /// + protected KeyMap(Hashtable map) + : this() + { + if (map != null) FromDictionary(map.IndexByString()); + } + + /// + /// The number of mapped keys. + /// + public int Count => _Map.Count; + + /// + /// Get or set mapping for a specified key. + /// + public T? this[string key] + { + get + { + return !string.IsNullOrEmpty(key) && _Map.TryGetValue(key, out var value) ? value : GetValueDefault(); + } + set + { + if (!string.IsNullOrEmpty(key) && value != null) + _Map[key] = value; + } + } + + /// + /// Try to get a mapping by key. + /// + /// The key. + /// Returns an array of mapped keys. + /// Returns true if the key was found. Otherwise false is returned. + public bool TryGetValue(string key, out T value) + { + return _Map.TryGetValue(key, out value); + } + + /// + /// + /// + /// + /// + public void Add(string key, T value) + { + _Map.Add(key, value); + } + + /// + public IEnumerator> GetEnumerator() + { + return ((IEnumerable>)_Map).GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Convert the instance into a dictionary. + /// + /// + public IDictionary ToDictionary() + { + return _Map; + } + + /// + /// Get the default for the type. + /// + protected virtual T? GetValueDefault() + { + return default; + } + + /// + /// Convert the type. + /// + protected virtual bool TryConvertValue(object o, out T? value) + { + value = default; + if (o is T t) + { + value = t; + return true; + } + return false; + } + + /// + /// Load a key map from an existing dictionary. + /// + internal void FromDictionary(IDictionary dictionary, string? prefix = null, Func? format = null) + { + foreach (var kv in dictionary) + { + if (TryKeyPrefix(kv.Key, prefix, out var suffix) && TryConvertValue(kv.Value, out var value) && value != null) + { + if (format != null) + suffix = format(suffix); + + _Map[suffix] = value; + } + } + } + + /// + /// Load values from environment variables into the option. + /// Keys that appear in both will replaced by environment variable values. + /// + /// Is raised if the environment helper is null. + internal void FromEnvironment(string? prefix = null, Func? format = null) + { + foreach (var kv in Environment.GetByPrefix(prefix)) + { + if (TryKeyPrefix(kv.Key, prefix, out var suffix) && TryConvertValue(kv.Value, out var value) && value != null) + { + if (format != null) + suffix = format(suffix); + + _Map[suffix] = value; + } + } + } + + /// + /// Try a key prefix. + /// + private static bool TryKeyPrefix(string key, string? prefix, out string suffix) + { + suffix = key; + if (prefix == null || prefix.Length == 0) + return true; + + if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + suffix = key.Substring(prefix.Length); + return true; + } + return false; + } +} diff --git a/src/PSRule.Types/Data/WildcardMap.cs b/src/PSRule.Types/Data/WildcardMap.cs new file mode 100644 index 0000000000..ba189a4b8d --- /dev/null +++ b/src/PSRule.Types/Data/WildcardMap.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.Data; + +/// +/// +/// +/// +public sealed class WildcardMap +{ + private readonly Dictionary _Map; + private List>? _Wildcard; + + /// + /// + /// + public WildcardMap() + { + _Map = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// + /// + /// + public WildcardMap(IEnumerable> values) + { + _Map = new Dictionary(StringComparer.OrdinalIgnoreCase); + Load(values); + } + + /// + /// + /// + /// + /// + /// + public bool TryGetValue(string key, out T? value) + { + value = default; + if (string.IsNullOrEmpty(key)) return false; + if (_Map.TryGetValue(key, out value)) return true; + + for (var i = 0; _Wildcard != null && i < _Wildcard.Count; i++) + { + if (key.Length > _Wildcard[i].Key.Length && key.StartsWith(_Wildcard[i].Key)) + { + value = _Wildcard[i].Value; + return true; + } + } + return false; + } + + private void Load(IEnumerable> dictionary) + { + Dictionary? wildcardIndex = null; + foreach (var kv in dictionary) + { + var index = kv.Key.IndexOf('*'); + + // Simple keys + if (index < 0) + { + _Map[kv.Key] = kv.Value; + } + // Wildcard keys + else + { + var key = kv.Key.Substring(0, index); + wildcardIndex ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + wildcardIndex[key] = kv.Value; + } + } + + if (wildcardIndex != null) + { + _Wildcard = new List>(); + foreach (var kv in wildcardIndex.OrderByDescending(s => s.Key)) + _Wildcard.Add(kv); + } + } +} diff --git a/src/PSRule.Types/Definitions/Rules/SeverityLevel.cs b/src/PSRule.Types/Definitions/Rules/SeverityLevel.cs index bf55cc6b4f..d117662e39 100644 --- a/src/PSRule.Types/Definitions/Rules/SeverityLevel.cs +++ b/src/PSRule.Types/Definitions/Rules/SeverityLevel.cs @@ -1,11 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + namespace PSRule.Definitions.Rules; /// /// If the rule fails, how serious is the result. /// +[JsonConverter(typeof(StringEnumConverter))] public enum SeverityLevel { /// diff --git a/src/PSRule.Types/Environment.cs b/src/PSRule.Types/Environment.cs index 07a0cd2641..a6c3e2bc08 100644 --- a/src/PSRule.Types/Environment.cs +++ b/src/PSRule.Types/Environment.cs @@ -312,14 +312,14 @@ public static string CombineEnvironmentVariable(params string[] args) /// /// Try to get any environment variable with a specific prefix. /// - public static IEnumerable> GetByPrefix(string prefix) + public static IEnumerable> GetByPrefix(string? prefix) { var env = System.Environment.GetEnvironmentVariables(); var enumerator = env.GetEnumerator(); while (enumerator.MoveNext()) { var key = enumerator.Key.ToString(); - if (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + if (prefix == null || key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) yield return new KeyValuePair(key, enumerator.Value); } } diff --git a/src/PSRule.Types/Options/IOverrideOption.cs b/src/PSRule.Types/Options/IOverrideOption.cs new file mode 100644 index 0000000000..54b00abc17 --- /dev/null +++ b/src/PSRule.Types/Options/IOverrideOption.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using PSRule.Data; +using PSRule.Definitions.Rules; + +namespace PSRule.Options; + +/// +/// Options that configure additional rule overrides. +/// +/// +/// See . +/// +public interface IOverrideOption +{ + /// + /// A mapping of rule severity levels to override. + /// + EnumMap? Level { get; } +} diff --git a/src/PSRule.Types/Options/OverrideOption.cs b/src/PSRule.Types/Options/OverrideOption.cs new file mode 100644 index 0000000000..3a83c203b2 --- /dev/null +++ b/src/PSRule.Types/Options/OverrideOption.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ComponentModel; +using PSRule.Data; +using PSRule.Definitions.Rules; + +namespace PSRule.Options; + +/// +/// Options that configure additional rule overrides. +/// +/// +/// See . +/// +public sealed class OverrideOption : IEquatable, IOption +{ + private const string ENVIRONMENT_LEVEL_KEY_PREFIX = "PSRULE_OVERRIDE_LEVEL_"; + private const string DICTIONARY_LEVEL_KEY_PREFIX = "Override.Level."; + + internal static readonly OverrideOption Default = new() + { + }; + + /// + /// Create an option instance. + /// + public OverrideOption() + { + Level = null; + } + + /// + /// Create an option instance based on an existing object. + /// + /// The existing object to copy. + public OverrideOption(OverrideOption option) + { + if (option == null) + return; + + Level = option.Level; + } + + /// + public override bool Equals(object obj) + { + return obj is OverrideOption option && Equals(option); + } + + /// + public bool Equals(OverrideOption other) + { + return other != null && + Level == other.Level; + } + + /// + public override int GetHashCode() + { + unchecked // Overflow is fine + { + var hash = 17; + hash = hash * 23 + (Level != null ? Level.GetHashCode() : 0); + return hash; + } + } + + /// + /// Combines two option instances into a new merged instance. + /// The new instance uses any non-null values from . + /// Any null values from are replaced with . + /// + public static OverrideOption Combine(OverrideOption o1, OverrideOption o2) + { + var result = new OverrideOption(o1) + { + Level = o1?.Level ?? o2?.Level, + }; + return result; + } + + /// + [DefaultValue(null)] + public EnumMap? Level { get; set; } + + /// + /// Load from environment variables. + /// + internal void Load() + { + var level = Level ?? []; + level.FromEnvironment(prefix: ENVIRONMENT_LEVEL_KEY_PREFIX); + Level = level.Count > 0 ? level : null; + } + + /// + /// Load from a dictionary. + /// + public void Import(IDictionary index) + { + var level = Level ?? []; + level.FromDictionary(index, prefix: DICTIONARY_LEVEL_KEY_PREFIX); + Level = level.Count > 0 ? level : null; + } +} diff --git a/src/PSRule/Commands/NewRuleDefinitionCommand.cs b/src/PSRule/Commands/NewRuleDefinitionCommand.cs index ae8e97ba22..1d1238a87c 100644 --- a/src/PSRule/Commands/NewRuleDefinitionCommand.cs +++ b/src/PSRule/Commands/NewRuleDefinitionCommand.cs @@ -131,13 +131,18 @@ protected override void ProcessRecord() displayName: Name, moduleName: source.Module ); + context.LanguageScope.TryGetOverride(id, out var propertyOverride); #pragma warning disable CA2000 // Dispose objects before losing scope, needs to be passed to pipeline var block = new RuleBlock( source: source, id: id, @ref: ResourceHelper.GetIdNullable(source.Module, Ref, ResourceIdKind.Ref), - level: level, + @default: new RuleProperties + { + Level = level + }, + @override: propertyOverride, info: info, condition: ps, tag: tag, diff --git a/src/PSRule/Common/BaselineJsonSerializationMapper.cs b/src/PSRule/Common/BaselineJsonSerializationMapper.cs index d12441baed..416d120a18 100644 --- a/src/PSRule/Common/BaselineJsonSerializationMapper.cs +++ b/src/PSRule/Common/BaselineJsonSerializationMapper.cs @@ -6,6 +6,7 @@ using PSRule.Configuration; using PSRule.Definitions; using PSRule.Definitions.Baselines; +using PSRule.Options; namespace PSRule; @@ -48,7 +49,7 @@ private static void MapBaselineSpec(JsonWriter writer, JsonSerializer serializer MapPropertyName(writer, propertyName); writer.WriteStartObject(); MapProperty(writer, serializer, nameof(baselineSpec.Configuration), baselineSpec.Configuration); - MapProperty(writer, nameof(baselineSpec.Convention), baselineSpec.Convention); + MapProperty(writer, serializer, nameof(baselineSpec.Override), baselineSpec.Override); MapProperty(writer, serializer, nameof(baselineSpec.Rule), baselineSpec.Rule); writer.WriteEndObject(); } @@ -152,6 +153,20 @@ private static void MapProperty(JsonWriter writer, string propertyName, Conventi writer.WriteEndObject(); } + /// + /// Map a OverrideOption property. + /// + private static void MapProperty(JsonWriter writer, JsonSerializer serializer, string propertyName, OverrideOption value) + { + if (value == null) + return; + + MapPropertyName(writer, propertyName); + writer.WriteStartObject(); + MapProperty(writer, serializer, nameof(value.Level), value.Level?.ToDictionary()); + writer.WriteEndObject(); + } + /// /// Map a RuleOption property. /// diff --git a/src/PSRule/Common/BaselineYamlSerializationMapper.cs b/src/PSRule/Common/BaselineYamlSerializationMapper.cs index 6e19c27c8b..643ee524b5 100644 --- a/src/PSRule/Common/BaselineYamlSerializationMapper.cs +++ b/src/PSRule/Common/BaselineYamlSerializationMapper.cs @@ -5,6 +5,7 @@ using PSRule.Configuration; using PSRule.Definitions; using PSRule.Definitions.Baselines; +using PSRule.Options; using YamlDotNet.Core; using YamlDotNet.Core.Events; @@ -51,7 +52,7 @@ private static void MapBaselineSpec(IEmitter emitter, string propertyName, Basel MapPropertyName(emitter, propertyName); emitter.Emit(new MappingStart()); MapProperty(emitter, nameof(baselineSpec.Configuration), baselineSpec.Configuration); - MapProperty(emitter, nameof(baselineSpec.Convention), baselineSpec.Convention); + MapProperty(emitter, nameof(baselineSpec.Override), baselineSpec.Override); MapProperty(emitter, nameof(baselineSpec.Rule), baselineSpec.Rule); emitter.Emit(new MappingEnd()); } @@ -155,6 +156,20 @@ private static void MapProperty(IEmitter emitter, string propertyName, Conventio emitter.Emit(new MappingEnd()); } + /// + /// Map a OverrideOption property. + /// + private static void MapProperty(IEmitter emitter, string propertyName, OverrideOption value) + { + if (value == null) + return; + + MapPropertyName(emitter, propertyName); + emitter.Emit(new MappingStart()); + MapProperty(emitter, nameof(value.Level), value.Level?.ToDictionary()); + emitter.Emit(new MappingEnd()); + } + /// /// Map a RuleOption property. /// diff --git a/src/PSRule/Common/JsonConverters.cs b/src/PSRule/Common/JsonConverters.cs index 820bc4c374..6b51012fe1 100644 --- a/src/PSRule/Common/JsonConverters.cs +++ b/src/PSRule/Common/JsonConverters.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json.Serialization; using PSRule.Annotations; using PSRule.Configuration; +using PSRule.Converters; using PSRule.Data; using PSRule.Definitions; using PSRule.Definitions.Baselines; @@ -976,6 +977,53 @@ public override void WriteJson(JsonWriter writer, ResourceId value, JsonSerializ } } +/// +/// A converter for converting to/ from JSON. +/// +internal sealed class EnumMapJsonConverter : JsonConverter where T : struct, Enum +{ + public override bool CanRead => true; + + public override bool CanWrite => false; + + public override bool CanConvert(Type objectType) + { + return typeof(EnumMap).IsAssignableFrom(objectType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var map = existingValue as EnumMap ?? new EnumMap(); + ReadMap(map, reader); + return map; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + private static void ReadMap(EnumMap map, JsonReader reader) + { + if (reader.TokenType != JsonToken.StartObject || !reader.Read()) + throw new PipelineSerializationException(PSRuleResources.ReadJsonFailed); + + string propertyName = null; + while (reader.TokenType != JsonToken.EndObject) + { + if (reader.TokenType == JsonToken.PropertyName) + { + propertyName = reader.Value.ToString(); + } + else if (reader.TokenType == JsonToken.String && TypeConverter.TryEnum(reader.Value, convert: true, out var value) && value != null) + { + map.Add(propertyName, value.Value); + } + reader.Read(); + } + } +} + /// /// A converter for converting to/ from JSON. /// diff --git a/src/PSRule/Common/YamlConverters.cs b/src/PSRule/Common/YamlConverters.cs index 888fcaa4b1..2bee8a8464 100644 --- a/src/PSRule/Common/YamlConverters.cs +++ b/src/PSRule/Common/YamlConverters.cs @@ -6,6 +6,7 @@ using System.Reflection; using PSRule.Annotations; using PSRule.Configuration; +using PSRule.Converters; using PSRule.Data; using PSRule.Definitions; using PSRule.Definitions.Expressions; @@ -1023,4 +1024,55 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ } } +/// +/// A converter for converting to/ from YAML. +/// +internal sealed class EnumMapYamlTypeConverter : IYamlTypeConverter where T : struct, Enum +{ + public bool Accepts(Type type) + { + return typeof(EnumMap).IsAssignableFrom(type); + } + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + var map = new EnumMap(); + if (parser.TryConsume(out _)) + { + while (parser.TryConsume(out var s1)) + { + var propertyName = s1.Value; + if (parser.TryConsume(out var s2) && TypeConverter.TryEnum(s2.Value, convert: true, out var value) && value != null) + map.Add(propertyName, value.Value); + } + parser.Require(); + parser.MoveNext(); + } + else + { + parser.SkipThisAndNestedEvents(); + } + return map; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) + { + if (type == typeof(EnumMap) && value == null) + { + emitter.Emit(new MappingStart()); + emitter.Emit(new MappingEnd()); + } + if (value is not EnumMap map) + return; + + emitter.Emit(new MappingStart()); + foreach (var kv in map) + { + emitter.Emit(new Scalar(kv.Key)); + emitter.Emit(new Scalar(kv.Value.ToString())); + } + emitter.Emit(new MappingEnd()); + } +} + #nullable restore diff --git a/src/PSRule/Configuration/BaselineOption.cs b/src/PSRule/Configuration/BaselineOption.cs index 1ca945dc34..0bb077b001 100644 --- a/src/PSRule/Configuration/BaselineOption.cs +++ b/src/PSRule/Configuration/BaselineOption.cs @@ -3,6 +3,7 @@ using System.Collections; using PSRule.Definitions.Baselines; +using PSRule.Options; namespace PSRule.Configuration; @@ -32,7 +33,7 @@ public BaselineInline() public ConfigurationOption Configuration { get; set; } - public ConventionOption Convention { get; set; } + public OverrideOption Override { get; set; } public RuleOption Rule { get; set; } } @@ -104,6 +105,7 @@ internal static void Load(IBaselineV1Spec option) // Rule.Tag - currently not supported // Process configuration values + option.Override.Load(); option.Configuration.Load(); } @@ -128,6 +130,7 @@ internal static void Load(IBaselineV1Spec option, Dictionary pro option.Rule.Tag = tag; // Process configuration values + option.Override.Import(properties); option.Configuration.Load(properties); } } diff --git a/src/PSRule/Configuration/PSRuleOption.cs b/src/PSRule/Configuration/PSRuleOption.cs index 479832390e..19543ad0d0 100644 --- a/src/PSRule/Configuration/PSRuleOption.cs +++ b/src/PSRule/Configuration/PSRuleOption.cs @@ -6,6 +6,8 @@ using System.Management.Automation; using PSRule.Converters.Yaml; using PSRule.Definitions.Baselines; +using PSRule.Definitions.Rules; +using PSRule.Options; using PSRule.Pipeline; using PSRule.Resources; using YamlDotNet.Core; @@ -34,11 +36,12 @@ public sealed class PSRuleOption : IEquatable, IBaselineV1Spec Baseline = Options.BaselineOption.Default, Binding = BindingOption.Default, Convention = ConventionOption.Default, - Execution = Options.ExecutionOption.Default, + Execution = ExecutionOption.Default, Include = IncludeOption.Default, Input = InputOption.Default, Logging = LoggingOption.Default, Output = OutputOption.Default, + Override = OverrideOption.Default, Rule = RuleOption.Default, }; @@ -52,11 +55,12 @@ public PSRuleOption() Binding = new BindingOption(); Configuration = new ConfigurationOption(); Convention = new ConventionOption(); - Execution = new Options.ExecutionOption(); + Execution = new ExecutionOption(); Include = new IncludeOption(); Input = new InputOption(); Logging = new LoggingOption(); Output = new OutputOption(); + Override = new OverrideOption(); Repository = new RepositoryOption(); Requires = new RequiresOption(); Rule = new RuleOption(); @@ -72,11 +76,12 @@ private PSRuleOption(string sourcePath, PSRuleOption option) Binding = new BindingOption(option?.Binding); Configuration = new ConfigurationOption(option?.Configuration); Convention = new ConventionOption(option?.Convention); - Execution = new Options.ExecutionOption(option?.Execution); + Execution = new ExecutionOption(option?.Execution); Include = new IncludeOption(option?.Include); Input = new InputOption(option?.Input); Logging = new LoggingOption(option?.Logging); Output = new OutputOption(option?.Output); + Override = new OverrideOption(option?.Override); Repository = new RepositoryOption(option?.Repository); Requires = new RequiresOption(option?.Requires); Rule = new RuleOption(option?.Rule); @@ -106,7 +111,7 @@ private PSRuleOption(string sourcePath, PSRuleOption option) /// /// Options that configure the execution sandbox. /// - public Options.ExecutionOption Execution { get; set; } + public ExecutionOption Execution { get; set; } /// /// Options that affect source locations imported for execution. @@ -128,6 +133,11 @@ private PSRuleOption(string sourcePath, PSRuleOption option) /// public OutputOption Output { get; set; } + /// + /// Options that configure additional rule overrides. + /// + public OverrideOption Override { get; set; } + /// /// Options for repository properties that are used by PSRule. /// @@ -199,16 +209,17 @@ public static PSRuleOption FromDefault() private static PSRuleOption Combine(PSRuleOption o1, PSRuleOption o2) { var result = new PSRuleOption(o1?._SourcePath ?? o2?._SourcePath, o1); - result.Baseline = Options.BaselineOption.Combine(result.Baseline, o2?.Baseline); - result.Binding = BindingOption.Combine(result.Binding, o2?.Binding); - result.Configuration = ConfigurationOption.Combine(result.Configuration, o2?.Configuration); - result.Convention = ConventionOption.Combine(result.Convention, o2?.Convention); - result.Execution = Options.ExecutionOption.Combine(result.Execution, o2?.Execution); - result.Include = IncludeOption.Combine(result.Include, o2?.Include); - result.Input = InputOption.Combine(result.Input, o2?.Input); - result.Logging = LoggingOption.Combine(result.Logging, o2?.Logging); - result.Repository = RepositoryOption.Combine(result.Repository, o2?.Repository); - result.Output = OutputOption.Combine(result.Output, o2?.Output); + result.Baseline = Options.BaselineOption.Combine(result?.Baseline, o2?.Baseline); + result.Binding = BindingOption.Combine(result?.Binding, o2?.Binding); + result.Configuration = ConfigurationOption.Combine(result?.Configuration, o2?.Configuration); + result.Convention = ConventionOption.Combine(result?.Convention, o2?.Convention); + result.Execution = ExecutionOption.Combine(result?.Execution, o2?.Execution); + result.Include = IncludeOption.Combine(result?.Include, o2?.Include); + result.Input = InputOption.Combine(result?.Input, o2?.Input); + result.Logging = LoggingOption.Combine(result?.Logging, o2?.Logging); + result.Output = OutputOption.Combine(result?.Output, o2?.Output); + result.Override = OverrideOption.Combine(result?.Override, o2?.Override); + result.Repository = RepositoryOption.Combine(result?.Repository, o2?.Repository); return result; } @@ -299,6 +310,7 @@ private static PSRuleOption FromYaml(string path, string yaml) .IgnoreUnmatchedProperties() .WithNamingConvention(CamelCaseNamingConvention.Instance) .WithTypeConverter(new FieldMapYamlTypeConverter()) + .WithTypeConverter(new EnumMapYamlTypeConverter()) .WithTypeConverter(new StringArrayMapConverter()) .WithTypeConverter(new SuppressionRuleYamlTypeConverter()) .WithTypeConverter(new PSObjectYamlTypeConverter()) @@ -336,6 +348,7 @@ private static PSRuleOption FromEnvironment(PSRuleOption option) option.Input.Load(); option.Logging.Load(); option.Output.Load(); + option.Override.Load(); option.Repository.Load(); option.Requires.Load(); BaselineOption.Load(option); @@ -366,6 +379,7 @@ public static PSRuleOption FromHashtable(Hashtable hashtable) option.Input.Load(index); option.Logging.Load(index); option.Output.Load(index); + option.Override.Import(index); option.Repository.Load(index); option.Requires.Load(index); BaselineOption.Load(option, index); @@ -431,6 +445,7 @@ public bool Equals(PSRuleOption other) Input == other.Input && Logging == other.Logging && Output == other.Output && + Override == other.Override && Suppression == other.Suppression && Repository == other.Repository && Rule == other.Rule; @@ -451,6 +466,7 @@ public override int GetHashCode() hash = hash * 23 + (Input != null ? Input.GetHashCode() : 0); hash = hash * 23 + (Logging != null ? Logging.GetHashCode() : 0); hash = hash * 23 + (Output != null ? Output.GetHashCode() : 0); + hash = hash * 23 + (Override != null ? Override.GetHashCode() : 0); hash = hash * 23 + (Suppression != null ? Suppression.GetHashCode() : 0); hash = hash * 23 + (Repository != null ? Repository.GetHashCode() : 0); hash = hash * 23 + (Rule != null ? Rule.GetHashCode() : 0); diff --git a/src/PSRule/Definitions/Baselines/BaselineSpec.cs b/src/PSRule/Definitions/Baselines/BaselineSpec.cs index 6753ee90d8..c1f6325373 100644 --- a/src/PSRule/Definitions/Baselines/BaselineSpec.cs +++ b/src/PSRule/Definitions/Baselines/BaselineSpec.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using PSRule.Configuration; +using PSRule.Options; namespace PSRule.Definitions.Baselines; @@ -18,4 +19,7 @@ public sealed class BaselineSpec : Spec, IBaselineV1Spec /// public RuleOption Rule { get; set; } + + /// + public OverrideOption Override { get; set; } } diff --git a/src/PSRule/Definitions/Baselines/IBaselineV1Spec.cs b/src/PSRule/Definitions/Baselines/IBaselineV1Spec.cs index 7024cdf25b..b5f592897d 100644 --- a/src/PSRule/Definitions/Baselines/IBaselineV1Spec.cs +++ b/src/PSRule/Definitions/Baselines/IBaselineV1Spec.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using PSRule.Configuration; +using PSRule.Options; namespace PSRule.Definitions.Baselines; @@ -16,12 +17,12 @@ internal interface IBaselineV1Spec ConfigurationOption Configuration { get; set; } /// - /// Options that configure conventions. + /// Options for that affect which rules are executed by including and filtering discovered rules. /// - ConventionOption Convention { get; set; } + RuleOption Rule { get; set; } /// - /// Options for that affect which rules are executed by including and filtering discovered rules. + /// Options that configure additional rule overrides. /// - RuleOption Rule { get; set; } + OverrideOption Override { get; set; } } diff --git a/src/PSRule/Definitions/Rules/RuleOverride.cs b/src/PSRule/Definitions/Rules/RuleOverride.cs new file mode 100644 index 0000000000..357e2cdd57 --- /dev/null +++ b/src/PSRule/Definitions/Rules/RuleOverride.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.Definitions.Rules; + +/// +/// Any overrides for the rule. +/// +public sealed class RuleOverride +{ + /// + /// If the rule fails, how serious is the result. + /// + public SeverityLevel? Level { get; set; } +} diff --git a/src/PSRule/Definitions/Rules/RuleProperties.cs b/src/PSRule/Definitions/Rules/RuleProperties.cs new file mode 100644 index 0000000000..81447924b1 --- /dev/null +++ b/src/PSRule/Definitions/Rules/RuleProperties.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.Definitions.Rules; + +/// +/// Any rule properties. +/// +public sealed class RuleProperties +{ + /// + /// If the rule fails, how serious is the result. + /// + public SeverityLevel Level { get; set; } +} diff --git a/src/PSRule/Common/SeverityLevelExtensions.cs b/src/PSRule/Definitions/Rules/SeverityLevelExtensions.cs similarity index 91% rename from src/PSRule/Common/SeverityLevelExtensions.cs rename to src/PSRule/Definitions/Rules/SeverityLevelExtensions.cs index b86cd4be91..84cc7ae613 100644 --- a/src/PSRule/Common/SeverityLevelExtensions.cs +++ b/src/PSRule/Definitions/Rules/SeverityLevelExtensions.cs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using PSRule.Definitions.Rules; - -namespace PSRule; +namespace PSRule.Definitions.Rules; internal static class SeverityLevelExtensions { diff --git a/src/PSRule/Host/HostHelper.cs b/src/PSRule/Host/HostHelper.cs index 35c63ea733..f2358f8553 100644 --- a/src/PSRule/Host/HostHelper.cs +++ b/src/PSRule/Host/HostHelper.cs @@ -261,6 +261,7 @@ private static ILanguageBlock[] GetYamlLanguageBlocks(Source[] sources, IResourc .WithTypeConverter(new StringArrayMapConverter()) .WithTypeConverter(new StringArrayConverter()) .WithTypeConverter(new PSObjectYamlTypeConverter()) + .WithTypeConverter(new EnumMapYamlTypeConverter()) .WithNodeTypeResolver(new PSOptionYamlTypeResolver()) .WithNodeDeserializer( inner => new ResourceNodeDeserializer(context, new LanguageExpressionDeserializer(context, inner)), @@ -327,6 +328,7 @@ private static ILanguageBlock[] GetJsonLanguageBlocks(Source[] sources, IResourc deserializer.Converters.Add(new FieldMapJsonConverter()); deserializer.Converters.Add(new StringArrayJsonConverter()); deserializer.Converters.Add(new LanguageExpressionJsonConverter(context)); + deserializer.Converters.Add(new EnumMapJsonConverter()); try { @@ -518,6 +520,7 @@ private static DependencyTargetCollection ToRuleBlockV1(ILanguageBloc var knownRuleNames = new HashSet(StringComparer.OrdinalIgnoreCase); var knownRuleIds = new HashSet(ResourceIdEqualityComparer.Default); + // Process from PowerShell foreach (var block in blocks.OfType()) { if (knownRuleIds.ContainsIds(block.Id, block.Ref, block.Alias, out var duplicateId)) @@ -537,6 +540,7 @@ private static DependencyTargetCollection ToRuleBlockV1(ILanguageBloc knownRuleIds.AddIds(block.Id, block.Ref, block.Alias); } + // Process from YAML/ JSON foreach (var block in blocks.OfType()) { var ruleName = block.Name; @@ -553,6 +557,7 @@ private static DependencyTargetCollection ToRuleBlockV1(ILanguageBloc } context.EnterLanguageScope(block.Source); + context.LanguageScope.TryGetOverride(block.Id, out var propertyOverride); try { var info = GetRuleHelpInfo(context, block) ?? new RuleHelpInfo( @@ -568,7 +573,11 @@ private static DependencyTargetCollection ToRuleBlockV1(ILanguageBloc source: block.Source, id: block.Id, @ref: block.Ref, - level: block.Level, + @default: new RuleProperties + { + Level = block.Level + }, + @override: propertyOverride, info: info, condition: new RuleVisitor(context, block.Id, block.Source, block.Spec), alias: block.Alias, diff --git a/src/PSRule/PSRule.psm1 b/src/PSRule/PSRule.psm1 index efc8092666..dcd8f9fe33 100644 --- a/src/PSRule/PSRule.psm1 +++ b/src/PSRule/PSRule.psm1 @@ -1326,6 +1326,10 @@ function New-PSRuleOption { [ValidateSet('Client', 'Plain', 'AzurePipelines', 'GitHubActions', 'VisualStudioCode', 'Detect')] [PSRule.Configuration.OutputStyle]$OutputStyle = [PSRule.Configuration.OutputStyle]::Detect, + # Sets the OverrideLevel option + [Parameter(Mandatory = $False)] + [Hashtable]$OverrideLevel, + # Sets the Repository.BaseRef option [Parameter(Mandatory = $False)] [String]$RepositoryBaseRef, @@ -1629,6 +1633,10 @@ function Set-PSRuleOption { [ValidateSet('Client', 'Plain', 'AzurePipelines', 'GitHubActions', 'VisualStudioCode', 'Detect')] [PSRule.Configuration.OutputStyle]$OutputStyle = [PSRule.Configuration.OutputStyle]::Detect, + # Sets the OverrideLevel option + [Parameter(Mandatory = $False)] + [Hashtable]$OverrideLevel, + # Sets the Repository.BaseRef option [Parameter(Mandatory = $False)] [String]$RepositoryBaseRef, @@ -2393,6 +2401,10 @@ function SetOptions { [ValidateSet('Client', 'Plain', 'AzurePipelines', 'GitHubActions', 'VisualStudioCode', 'Detect')] [PSRule.Configuration.OutputStyle]$OutputStyle = [PSRule.Configuration.OutputStyle]::Detect, + # Sets the OverrideLevel option + [Parameter(Mandatory = $False)] + [Hashtable]$OverrideLevel, + # Sets the Repository.BaseRef option [Parameter(Mandatory = $False)] [String]$RepositoryBaseRef, @@ -2643,6 +2655,11 @@ function SetOptions { $Option.Output.Style = $OutputStyle; } + # Sets option Override.Level + if ($PSBoundParameters.ContainsKey('OverrideLevel')) { + $Option.Override.Level = $OverrideLevel; + } + # Sets option Repository.BaseRef if ($PSBoundParameters.ContainsKey('RepositoryBaseRef')) { $Option.Repository.BaseRef = $RepositoryBaseRef; diff --git a/src/PSRule/Pipeline/Formatters/AzurePipelinesFormatter.cs b/src/PSRule/Pipeline/Formatters/AzurePipelinesFormatter.cs index 0a7dc1ee1b..c6b1a17379 100644 --- a/src/PSRule/Pipeline/Formatters/AzurePipelinesFormatter.cs +++ b/src/PSRule/Pipeline/Formatters/AzurePipelinesFormatter.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using PSRule.Configuration; -using PSRule.Definitions.Rules; using PSRule.Rules; namespace PSRule.Pipeline.Formatters; @@ -51,13 +50,13 @@ protected override void FailDetail(RuleRecord record) { base.FailDetail(record); var message = GetFailMessage(record); - if (record.Level == SeverityLevel.Error) + if (record.Level == Definitions.Rules.SeverityLevel.Error) Error(message); - if (record.Level == SeverityLevel.Warning) + if (record.Level == Definitions.Rules.SeverityLevel.Warning) Warning(message); - if (record.Level != SeverityLevel.Information) + if (record.Level != Definitions.Rules.SeverityLevel.Information) LineBreak(); } } diff --git a/src/PSRule/Pipeline/Formatters/GitHubActionsFormatter.cs b/src/PSRule/Pipeline/Formatters/GitHubActionsFormatter.cs index 3c57d3fa7c..e1baf01fa9 100644 --- a/src/PSRule/Pipeline/Formatters/GitHubActionsFormatter.cs +++ b/src/PSRule/Pipeline/Formatters/GitHubActionsFormatter.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using PSRule.Configuration; -using PSRule.Definitions.Rules; using PSRule.Rules; namespace PSRule.Pipeline.Formatters; @@ -53,13 +52,13 @@ protected override void FailDetail(RuleRecord record) { base.FailDetail(record); var message = GetFailMessage(record); - if (record.Level == SeverityLevel.Error) + if (record.Level == Definitions.Rules.SeverityLevel.Error) Error(message); - if (record.Level == SeverityLevel.Warning) + if (record.Level == Definitions.Rules.SeverityLevel.Warning) Warning(message); - if (record.Level == SeverityLevel.Information) + if (record.Level == Definitions.Rules.SeverityLevel.Information) Information(message); LineBreak(); diff --git a/src/PSRule/Pipeline/OptionContext.cs b/src/PSRule/Pipeline/OptionContext.cs index 91a9a6466d..db7e529820 100644 --- a/src/PSRule/Pipeline/OptionContext.cs +++ b/src/PSRule/Pipeline/OptionContext.cs @@ -51,6 +51,8 @@ public ConventionOption Convention public OutputOption Output { get; set; } + public OverrideOption Override { get; set; } + public RepositoryOption Repository { get; set; } public RequiresOption Requires { get; set; } diff --git a/src/PSRule/Pipeline/OptionContextBuilder.cs b/src/PSRule/Pipeline/OptionContextBuilder.cs index f37c94b506..7d576ac6b1 100644 --- a/src/PSRule/Pipeline/OptionContextBuilder.cs +++ b/src/PSRule/Pipeline/OptionContextBuilder.cs @@ -150,6 +150,7 @@ private static void Combine(OptionContext context, OptionScope optionScope) context.Input = InputOption.Combine(context.Input, optionScope.Input); context.Logging = LoggingOption.Combine(context.Logging, optionScope.Logging); context.Output = OutputOption.Combine(context.Output, optionScope.Output); + context.Override = OverrideOption.Combine(context.Override, optionScope.Override); context.Repository = RepositoryOption.Combine(context.Repository, optionScope.Repository); context.Requires = RequiresOption.Combine(context.Requires, optionScope.Requires); context.Rule = RuleOption.Combine(context.Rule, optionScope.Rule); diff --git a/src/PSRule/Pipeline/OptionScope.cs b/src/PSRule/Pipeline/OptionScope.cs index 2494db4f07..c41e7f12f1 100644 --- a/src/PSRule/Pipeline/OptionScope.cs +++ b/src/PSRule/Pipeline/OptionScope.cs @@ -69,6 +69,8 @@ private OptionScope(ScopeType type, string languageScope) public OutputOption Output { get; set; } + public OverrideOption Override { get; set; } + public RepositoryOption Repository { get; set; } public RequiresOption Requires { get; set; } @@ -109,6 +111,7 @@ public static OptionScope FromWorkspace(PSRuleOption option) Input = option.Input, Logging = option.Logging, Output = option.Output, + Override = option.Override, Repository = option.Repository, Requires = option.Requires, Rule = option.Rule, @@ -146,7 +149,8 @@ internal static OptionScope FromBaseline(ScopeType type, string baselineId, stri Tag = spec.Rule?.Tag, Labels = spec.Rule?.Labels, IncludeLocal = type == ScopeType.Explicit ? false : null - } + }, + Override = spec.Override }; } diff --git a/src/PSRule/Pipeline/Output/SarifBuilder.cs b/src/PSRule/Pipeline/Output/SarifBuilder.cs index 187aadb002..e51d5b1dd1 100644 --- a/src/PSRule/Pipeline/Output/SarifBuilder.cs +++ b/src/PSRule/Pipeline/Output/SarifBuilder.cs @@ -41,13 +41,13 @@ internal sealed class SarifBuilder public SarifBuilder(Source[] source, PSRuleOption option) { _Option = option; - _Rules = new Dictionary(); - _Extensions = new Dictionary(); - _Artifacts = new Dictionary(); + _Rules = []; + _Extensions = []; + _Artifacts = []; _Run = new Run { Tool = GetTool(source), - Results = new List(), + Results = [], Invocations = GetInvocation(), AutomationDetails = GetAutomationDetails(), OriginalUriBaseIds = GetBaseIds(), @@ -279,8 +279,8 @@ private ReportingDescriptorReference AddRule(RuleRecord record, string id) DefaultConfiguration = new ReportingConfiguration { Enabled = true, - Level = GetLevel(record), - } + Level = GetLevel(record.Default.Level), + }, }; toolComponent.Rules.Add(descriptor); @@ -294,9 +294,27 @@ private ReportingDescriptorReference AddRule(RuleRecord record, string id) Guid = toolComponent.Guid, Name = toolComponent.Name, Index = _Run.Tool.Extensions == null ? -1 : _Run.Tool.Extensions.IndexOf(toolComponent), - } + }, }; + _Rules.Add(id, descriptorReference); + + // Create a configuration override if applicable. + if (record.Override != null && record.Override.Level.HasValue && record.Override.Level.Value != SeverityLevel.None && record.Override.Level != record.Default.Level) + { + if (_Run.Invocations[0].RuleConfigurationOverrides == null) + _Run.Invocations[0].RuleConfigurationOverrides = []; + + _Run.Invocations[0].RuleConfigurationOverrides.Add(new ConfigurationOverride + { + Descriptor = descriptorReference, + Configuration = new ReportingConfiguration + { + Level = GetLevel(record.Override.Level.Value), + } + }); + } + return descriptorReference; } @@ -417,10 +435,24 @@ 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 switch + { + SeverityLevel.Error => FailureLevel.Error, + SeverityLevel.Warning => FailureLevel.Warning, + SeverityLevel.Information => FailureLevel.Note, + _ => FailureLevel.None, + }; + } - return record.Level == SeverityLevel.Warning ? FailureLevel.Warning : FailureLevel.Note; + private static FailureLevel GetLevel(SeverityLevel level) + { + return level switch + { + SeverityLevel.Error => FailureLevel.Error, + SeverityLevel.Warning => FailureLevel.Warning, + SeverityLevel.Information => FailureLevel.Note, + _ => FailureLevel.None, + }; } private Tool GetTool(Source[] source) diff --git a/src/PSRule/Pipeline/Output/YamlOutputWriter.cs b/src/PSRule/Pipeline/Output/YamlOutputWriter.cs index e6106444f9..84fadb7e87 100644 --- a/src/PSRule/Pipeline/Output/YamlOutputWriter.cs +++ b/src/PSRule/Pipeline/Output/YamlOutputWriter.cs @@ -3,6 +3,7 @@ using PSRule.Configuration; using PSRule.Definitions.Baselines; +using PSRule.Definitions.Rules; using YamlDotNet.Core; using YamlDotNet.Core.Events; using YamlDotNet.Serialization; @@ -29,6 +30,7 @@ internal static string ToYaml(object[] o) .WithTypeConverter(new PSObjectYamlTypeConverter()) .WithTypeConverter(new FieldMapYamlTypeConverter()) .WithTypeConverter(new InfoStringYamlTypeConverter()) + .WithTypeConverter(new EnumMapYamlTypeConverter()) .WithNamingConvention(CamelCaseNamingConvention.Instance) .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitDefaults) .Build(); diff --git a/src/PSRule/Pipeline/PipelineBuilderBase.cs b/src/PSRule/Pipeline/PipelineBuilderBase.cs index 29713f10f0..f4c85a17d3 100644 --- a/src/PSRule/Pipeline/PipelineBuilderBase.cs +++ b/src/PSRule/Pipeline/PipelineBuilderBase.cs @@ -93,6 +93,7 @@ public virtual IPipelineBuilder Configure(PSRuleOption option) Option.Output.Outcome ??= OutputOption.Default.Outcome; Option.Output.Banner ??= OutputOption.Default.Banner; Option.Output.Style = GetStyle(option.Output.Style ?? OutputOption.Default.Style.Value); + Option.Override = new OverrideOption(option.Override); Option.Repository = GetRepository(option.Repository); return this; } diff --git a/src/PSRule/Rules/RuleBlock.cs b/src/PSRule/Rules/RuleBlock.cs index 22cbf088c5..ebe46daff8 100644 --- a/src/PSRule/Rules/RuleBlock.cs +++ b/src/PSRule/Rules/RuleBlock.cs @@ -23,7 +23,7 @@ namespace PSRule.Rules; [DebuggerDisplay("{Id} @{Source.Path}")] internal sealed class RuleBlock : ILanguageBlock, IDependencyTarget, IDisposable, IResource, IRuleV1 { - internal RuleBlock(ISourceFile source, ResourceId id, ResourceId? @ref, SeverityLevel level, RuleHelpInfo info, ICondition condition, IResourceTags tag, ResourceId[] alias, ResourceId[] dependsOn, Hashtable configuration, ISourceExtent extent, ResourceFlags flags, IResourceLabels labels) + internal RuleBlock(ISourceFile source, ResourceId id, ResourceId? @ref, RuleProperties @default, RuleOverride @override, RuleHelpInfo info, ICondition condition, IResourceTags tag, ResourceId[] alias, ResourceId[] dependsOn, Hashtable configuration, ISourceExtent extent, ResourceFlags flags, IResourceLabels labels) { Source = source; Name = id.Name; @@ -33,7 +33,9 @@ internal RuleBlock(ISourceFile source, ResourceId id, ResourceId? @ref, Severity Ref = @ref; Alias = alias; - Level = level; + Default = @default; + Override = @override; + Level = Override != null && Override.Level.HasValue && Override.Level != SeverityLevel.None ? Override.Level.Value : Default.Level; Info = info; Condition = condition; Tag = tag; @@ -106,6 +108,14 @@ internal RuleBlock(ISourceFile source, ResourceId id, ResourceId? @ref, Severity [YamlIgnore] public ResourceFlags Flags { get; } + [JsonIgnore] + [YamlIgnore] + public RuleProperties Default { get; } + + [JsonIgnore] + [YamlIgnore] + public RuleOverride Override { get; } + ResourceId[] IDependencyTarget.DependsOn => DependsOn; bool IDependencyTarget.Dependency => Source.IsDependency(); diff --git a/src/PSRule/Rules/RuleRecord.cs b/src/PSRule/Rules/RuleRecord.cs index 6a71cc53f9..bd994ef462 100644 --- a/src/PSRule/Rules/RuleRecord.cs +++ b/src/PSRule/Rules/RuleRecord.cs @@ -14,6 +14,8 @@ namespace PSRule.Rules; +#nullable enable + /// /// A detailed format for rule results. /// @@ -25,7 +27,7 @@ public sealed class RuleRecord : IDetailedRuleResultV2 internal readonly ResultDetail _Detail; - internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject targetObject, string targetName, string targetType, IResourceTags tag, RuleHelpInfo info, Hashtable field, SeverityLevel level, ISourceExtent extent, RuleOutcome outcome = RuleOutcome.None, RuleOutcomeReason reason = RuleOutcomeReason.None) + internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject targetObject, string targetName, string targetType, IResourceTags tag, RuleHelpInfo info, Hashtable field, RuleProperties @default, ISourceExtent extent, RuleOutcome outcome = RuleOutcome.None, RuleOutcomeReason reason = RuleOutcomeReason.None, RuleOverride? @override = null) { _TargetObject = targetObject; RunId = runId; @@ -39,8 +41,11 @@ internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject t OutcomeReason = reason; Info = info; Source = targetObject.Source.GetSourceInfo(); - Level = level; Extent = extent; + Default = @default; + Override = @override; + Level = Override?.Level ?? Default.Level; + _Detail = new ResultDetail(); if (tag != null) Tag = tag.ToHashtable(); @@ -59,7 +64,7 @@ internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject t /// A unique identifier for the rule. /// /// - /// An additional opaque identifer may also be provided by by . + /// An additional opaque identifier may also be provided by by . /// [JsonIgnore] [YamlIgnore] @@ -106,14 +111,14 @@ internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject t /// [JsonIgnore] [YamlIgnore] - public string Recommendation => Info.Recommendation?.Text ?? Info.Synopsis?.Text; + public string? Recommendation => Info.Recommendation?.Text ?? Info.Synopsis?.Text; /// /// The reason for the failed condition. /// [DefaultValue(null)] [JsonProperty(PropertyName = "reason")] - public string[] Reason => _Detail.Count > 0 ? _Detail.GetReasonStrings() : null; + public string[]? Reason => _Detail.Count > 0 ? _Detail.GetReasonStrings() : null; /// /// A name to identify the target object. @@ -189,6 +194,20 @@ internal RuleRecord(string runId, ResourceId ruleId, string @ref, TargetObject t [YamlMember()] public IResultDetailV2 Detail => _Detail; + /// + /// Any default properties for the rule. + /// + [JsonIgnore] + [YamlIgnore] + public RuleProperties Default { get; set; } + + /// + /// Any overrides for the rule. + /// + [JsonIgnore] + [YamlIgnore] + public RuleOverride? Override { get; } + /// /// Determine if the rule is successful or skipped. /// @@ -221,3 +240,5 @@ internal bool HasSource() return Source != null && Source.Length > 0; } } + +#nullable restore diff --git a/src/PSRule/Runtime/ILanguageScope.cs b/src/PSRule/Runtime/ILanguageScope.cs index f0caba0788..b2e144073e 100644 --- a/src/PSRule/Runtime/ILanguageScope.cs +++ b/src/PSRule/Runtime/ILanguageScope.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using PSRule.Definitions; +using PSRule.Definitions.Rules; using PSRule.Pipeline; using PSRule.Runtime.Binding; @@ -67,6 +68,11 @@ internal interface ILanguageScope : IDisposable /// Try to bind the name of the object. /// bool TryGetName(object o, out string? name, out string? path); + + /// + /// Try to get a rule override by resource ID. + /// + bool TryGetOverride(ResourceId id, out RuleOverride? propertyOverride); } #nullable restore diff --git a/src/PSRule/Runtime/LanguageScope.cs b/src/PSRule/Runtime/LanguageScope.cs index 5a3bfbdd72..9484721700 100644 --- a/src/PSRule/Runtime/LanguageScope.cs +++ b/src/PSRule/Runtime/LanguageScope.cs @@ -3,7 +3,10 @@ using System.Diagnostics; using PSRule.Configuration; +using PSRule.Data; using PSRule.Definitions; +using PSRule.Definitions.Rules; +using PSRule.Options; using PSRule.Pipeline; using PSRule.Runtime.Binding; @@ -15,6 +18,7 @@ namespace PSRule.Runtime; internal sealed class LanguageScope : ILanguageScope { private IDictionary? _Configuration; + private WildcardMap? _Override; private readonly Dictionary _Service; private readonly Dictionary _Filter; private ITargetBinder? _TargetBinder; @@ -59,6 +63,18 @@ public void Configure(OptionContext context) var builder = new TargetBinderBuilder(context.BindTargetName, context.BindTargetType, context.BindField, context.InputTargetType); _TargetBinder = builder.Build(context.Binding); + _Override = WithOverride(context.Override); + } + + private static WildcardMap? WithOverride(OverrideOption option) + { + if (option == null || option.Level == null) + return default; + + var overrides = option.Level + .Where(l => l.Value != SeverityLevel.None) + .Select(l => new KeyValuePair(l.Key, new RuleOverride { Level = l.Value })); + return new WildcardMap(overrides); } /// @@ -68,6 +84,17 @@ public bool TryConfigurationValue(string key, out object? value) return !string.IsNullOrEmpty(key) && _Configuration != null && _Configuration.TryGetValue(key, out value); } + /// + public bool TryGetOverride(ResourceId id, out RuleOverride? value) + { + value = default; + if (_Override == null) + return false; + + return _Override.TryGetValue(id.Value, out value) || + _Override.TryGetValue(id.Name, out value); + } + /// public void WithFilter(IResourceFilter resourceFilter) { diff --git a/src/PSRule/Runtime/RunspaceContext.cs b/src/PSRule/Runtime/RunspaceContext.cs index 1adc772fc9..da6b553868 100644 --- a/src/PSRule/Runtime/RunspaceContext.cs +++ b/src/PSRule/Runtime/RunspaceContext.cs @@ -596,9 +596,10 @@ public RuleRecord EnterRuleBlock(RuleBlock ruleBlock) targetType: Binding?.TargetType, tag: ruleBlock.Tag, info: ruleBlock.Info, - field: Binding?.Field, - level: ruleBlock.Level, - extent: ruleBlock.Extent + field: Binding.Field, + @default: ruleBlock.Default, + extent: ruleBlock.Extent, + @override: ruleBlock.Override ); Writer?.EnterScope(ruleBlock.Name); diff --git a/tests/PSRule.Tests/AssertFormatterTests.cs b/tests/PSRule.Tests/AssertFormatterTests.cs index 995c421abf..5a77712470 100644 --- a/tests/PSRule.Tests/AssertFormatterTests.cs +++ b/tests/PSRule.Tests/AssertFormatterTests.cs @@ -392,7 +392,10 @@ private static InvokeResult GetPassResult() tag: new ResourceTags(), info: new RuleHelpInfo("Test", "Test rule", null), field: null, - level: SeverityLevel.Error, + @default: new RuleProperties + { + Level = SeverityLevel.Error + }, extent: null, outcome: RuleOutcome.Pass, reason: RuleOutcomeReason.Processed @@ -414,7 +417,10 @@ private static InvokeResult GetFailResult(SeverityLevel level = SeverityLevel.Er tag: new ResourceTags(), info: new RuleHelpInfo("Test1", "Test rule", null), field: null, - level: level, + @default: new RuleProperties + { + Level = level + }, extent: null, outcome: RuleOutcome.Fail, reason: RuleOutcomeReason.Processed @@ -430,7 +436,10 @@ private static InvokeResult GetFailResult(SeverityLevel level = SeverityLevel.Er tag: new ResourceTags(), info: new RuleHelpInfo("Test2", "Test rule", null), field: null, - level: level, + @default: new RuleProperties + { + Level = level + }, extent: null, outcome: RuleOutcome.Fail, reason: RuleOutcomeReason.Processed diff --git a/tests/PSRule.Tests/BaseTests.cs b/tests/PSRule.Tests/BaseTests.cs index 97c7ee0e25..d344586d2a 100644 --- a/tests/PSRule.Tests/BaseTests.cs +++ b/tests/PSRule.Tests/BaseTests.cs @@ -10,6 +10,8 @@ namespace PSRule; +#nullable enable + /// /// A base class for all tests. /// @@ -22,7 +24,12 @@ protected virtual PSRuleOption GetOption() return new PSRuleOption(); } - internal TestWriter GetTestWriter(PSRuleOption option = default) + protected virtual PSRuleOption GetOption(string path) + { + return PSRuleOption.FromFile(path); + } + + internal TestWriter GetTestWriter(PSRuleOption? option = default) { return new TestWriter(option ?? GetOption()); } @@ -63,3 +70,5 @@ protected static PSObject GetObject(params (string name, object value)[] propert #endregion Helper methods } + +#nullable restore diff --git a/tests/PSRule.Tests/Baseline.Rule.jsonc b/tests/PSRule.Tests/Baseline.Rule.jsonc index af3a3401cb..72ef0b8d3d 100644 --- a/tests/PSRule.Tests/Baseline.Rule.jsonc +++ b/tests/PSRule.Tests/Baseline.Rule.jsonc @@ -79,6 +79,11 @@ "low" ] } + }, + "override": { + "level": { + "rule1": "Warning" + } } } }, diff --git a/tests/PSRule.Tests/Baseline.Rule.yaml b/tests/PSRule.Tests/Baseline.Rule.yaml index a31b10d658..4ab0fc4afd 100644 --- a/tests/PSRule.Tests/Baseline.Rule.yaml +++ b/tests/PSRule.Tests/Baseline.Rule.yaml @@ -1,4 +1,3 @@ - --- # Synopsis: This is an example baseline apiVersion: github.com/microsoft/PSRule/v1 @@ -11,14 +10,14 @@ spec: # Additional comment rule: include: - # Additional comment - - 'WithBaseline' - # Additional comment + # Additional comment + - 'WithBaseline' + # Additional comment configuration: key1: value1 key2: - - value1: abc - - value2: def + - value1: abc + - value2: def --- # Synopsis: This is an example baseline @@ -29,7 +28,7 @@ metadata: spec: rule: include: - - '' + - '' configuration: key1: value1 @@ -54,9 +53,11 @@ spec: rule: tag: severity: - - 'high' - - 'low' - + - 'high' + - 'low' + override: + level: + rule1: Warning --- # Synopsis: This is an example obsolete baseline apiVersion: github.com/microsoft/PSRule/v1 @@ -65,7 +66,7 @@ metadata: name: TestBaseline5 annotations: obsolete: true -spec: { } +spec: {} --- # Synopsis: An example of a baseline with labels defined @@ -76,4 +77,4 @@ metadata: spec: rule: labels: - framework.v1/control: [ 'c-1', 'c-2' ] + framework.v1/control: ['c-1', 'c-2'] diff --git a/tests/PSRule.Tests/BaselineTests.cs b/tests/PSRule.Tests/BaselineTests.cs index 9dcd941d49..6c25569eeb 100644 --- a/tests/PSRule.Tests/BaselineTests.cs +++ b/tests/PSRule.Tests/BaselineTests.cs @@ -8,6 +8,7 @@ using Newtonsoft.Json.Linq; using PSRule.Definitions; using PSRule.Definitions.Baselines; +using PSRule.Definitions.Rules; using PSRule.Host; using PSRule.Pipeline; using PSRule.Pipeline.Output; @@ -49,6 +50,7 @@ public void ReadBaselineYaml() // TestBaseline4 Assert.Equal("TestBaseline4", baseline[3].Name); Assert.Null(baseline[3].Info.Synopsis.Text); + Assert.Equal(SeverityLevel.Warning, baseline[3].Spec.Override.Level["rule1"]); // TestBaseline5 Assert.Equal("TestBaseline5", baseline[4].Name); @@ -90,6 +92,7 @@ public void ReadBaselineJson() // TestBaseline4 Assert.Equal("TestBaseline4", baseline[3].Name); Assert.Null(baseline[3].Info.Synopsis.Text); + Assert.Equal(SeverityLevel.Warning, baseline[3].Spec.Override.Level["rule1"]); // TestBaseline5 Assert.Equal("TestBaseline5", baseline[4].Name); @@ -153,6 +156,13 @@ public void BaselineAsYaml() Assert.Equal("value1", actual[0]["spec"]["configuration"]["key1"]); Assert.Equal("abc", actual[0]["spec"]["configuration"]["key2"][0]["value1"]); Assert.Equal("def", actual[0]["spec"]["configuration"]["key2"][1]["value2"]); + + // TestBaseline4 + Assert.Equal("github.com/microsoft/PSRule/v1", actual[3]["apiVersion"]); + Assert.Equal("Baseline", actual[3]["kind"]); + Assert.Equal("TestBaseline4", actual[3]["metadata"]["name"]); + Assert.NotNull(actual[3]["spec"]); + Assert.Equal("Warning", actual[3]["spec"]["override"]["level"]["rule1"]); } [Fact] @@ -171,6 +181,13 @@ public void BaselineAsJson() Assert.Equal("value1", actual?[0]["spec"]?["configuration"]?["key1"]); Assert.Equal("abc", actual?[0]["spec"]?["configuration"]?["key2"]?[0]?["value1"]); Assert.Equal("def", actual?[0]["spec"]?["configuration"]?["key2"]?[1]?["value2"]); + + // TestBaseline4 + Assert.Equal("github.com/microsoft/PSRule/v1", actual?[3]["apiVersion"]); + Assert.Equal("Baseline", actual?[3]["kind"]); + Assert.Equal("TestBaseline4", actual?[3]["metadata"]?["name"]); + Assert.NotNull(actual?[3]["spec"]); + Assert.Equal("Warning", actual?[3]["spec"]?["override"]?["level"]?["rule1"]); } #region Helper methods diff --git a/tests/PSRule.Tests/MockLanguageScope.cs b/tests/PSRule.Tests/MockLanguageScope.cs index 05c18d73c4..72277e6acd 100644 --- a/tests/PSRule.Tests/MockLanguageScope.cs +++ b/tests/PSRule.Tests/MockLanguageScope.cs @@ -76,6 +76,16 @@ public bool TryGetName(object o, out string name, out string path) throw new NotImplementedException(); } + public bool TryGetOverride(ResourceId id, out RuleOverride propertyOverride) + { + throw new NotImplementedException(); + } + + public bool TryGetScope(object o, out string[] scope) + { + throw new NotImplementedException(); + } + public bool TryGetType(object o, out string type, out string path) { throw new NotImplementedException(); diff --git a/tests/PSRule.Tests/OutputWriterTests.cs b/tests/PSRule.Tests/OutputWriterTests.cs index 1ac916986f..0d3948b529 100644 --- a/tests/PSRule.Tests/OutputWriterTests.cs +++ b/tests/PSRule.Tests/OutputWriterTests.cs @@ -88,7 +88,7 @@ public void SarifProblemsOnly() Assert.Equal("rid-002", actual["runs"][0]["results"][0]["ruleId"].Value()); Assert.Equal("error", actual["runs"][0]["results"][0]["level"].Value()); - // Fail with warning + // Fail with warning (default value is omitted) Assert.Equal("rid-003", actual["runs"][0]["results"][1]["ruleId"].Value()); Assert.Null(actual["runs"][0]["results"][1]["level"]); @@ -97,6 +97,51 @@ public void SarifProblemsOnly() Assert.Equal("note", actual["runs"][0]["results"][2]["level"].Value()); } + [Fact] + public void Output_WhenRuleLevelIsOverridden_ShouldReturnOverrideInSarifFormat() + { + var option = GetOption(); + var output = new TestWriter(option); + var result = new InvokeResult(); + result.Add(GetPass()); + result.Add(GetFail()); + result.Add(GetFail("rid-003", SeverityLevel.Warning, overrideLevel: SeverityLevel.Information)); + result.Add(GetFail("rid-004", SeverityLevel.Information, overrideLevel: SeverityLevel.Warning)); + var writer = new SarifOutputWriter(null, output, option, null); + writer.Begin(); + writer.WriteObject(result, false); + writer.End(new DefaultPipelineResult(null, Options.BreakLevel.None)); + + var doc = JsonConvert.DeserializeObject(output.Output.OfType().FirstOrDefault()); + Assert.NotNull(doc); + Assert.Equal("PSRule", doc["runs"][0]["tool"]["driver"]["name"].Value()); + Assert.Equal("0.0.1", doc["runs"][0]["tool"]["driver"]["semanticVersion"].Value().Split('+')[0]); + + // Fail with error + var actual = doc["runs"][0]["results"].Where(r => r["ruleId"].Value() == "rid-002").FirstOrDefault(); + Assert.Equal("error", actual["level"].Value()); + + // Fail with note + actual = doc["runs"][0]["results"].Where(r => r["ruleId"].Value() == "rid-003").FirstOrDefault(); + Assert.Equal("note", actual["level"].Value()); + + var ruleDefault = doc["runs"][0]["tool"]["driver"]["rules"].Where(r => r["id"].Value() == "rid-003").FirstOrDefault(); + Assert.Null(ruleDefault["defaultConfiguration"]); + + var ruleOverride = doc["runs"][0]["invocations"][0]["ruleConfigurationOverrides"].Where(r => r["descriptor"]["id"].Value() == "rid-003").FirstOrDefault(); + Assert.Equal("note", actual["level"].Value()); + + // Fail with warning (default value is omitted) + actual = doc["runs"][0]["results"].Where(r => r["ruleId"].Value() == "rid-004").FirstOrDefault(); + Assert.Null(actual["level"]); + + ruleDefault = doc["runs"][0]["tool"]["driver"]["rules"].Where(r => r["id"].Value() == "rid-004").FirstOrDefault(); + Assert.Equal("note", ruleDefault["defaultConfiguration"]["level"].Value()); + + ruleOverride = doc["runs"][0]["invocations"][0]["ruleConfigurationOverrides"].Where(r => r["descriptor"]["id"].Value() == "rid-004").FirstOrDefault(); + Assert.Null(actual["level"]); + } + [Fact] public void Yaml() { @@ -326,7 +371,7 @@ public void NUnit3() result.Add(GetPass()); result.Add(GetFail()); result.Add(GetFail("rid-003", SeverityLevel.Warning)); - result.Add(GetFail("rid-004", SeverityLevel.Information, "Synopsis \"with quotes\".")); + result.Add(GetFail("rid-004", SeverityLevel.Information, synopsis: "Synopsis \"with quotes\".")); var writer = new NUnit3OutputWriter(output, option, null); writer.Begin(); writer.WriteObject(result, false); @@ -413,7 +458,10 @@ private static RuleRecord GetPass() recommendation: new InfoString("Recommendation for rule 001\r\nover two lines.") ), field: new Hashtable(), - level: SeverityLevel.Error, + @default: new RuleProperties + { + Level = SeverityLevel.Error + }, extent: null, outcome: RuleOutcome.Pass, reason: RuleOutcomeReason.Processed @@ -423,7 +471,7 @@ 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") + private static RuleRecord GetFail(string ruleRef = "rid-002", SeverityLevel level = SeverityLevel.Error, SeverityLevel? overrideLevel = null, string synopsis = "This is rule 002.", string ruleId = "TestModule\\rule-002") { var info = new RuleHelpInfo( "rule-002", @@ -432,10 +480,17 @@ private static RuleRecord GetFail(string ruleRef = "rid-002", SeverityLevel leve synopsis: new InfoString(synopsis), recommendation: new InfoString("Recommendation for rule 002") ); + info.Annotations = new Hashtable { ["annotation-data"] = "Custom annotation" }; + + var ruleOverride = overrideLevel == null ? null : new RuleOverride + { + Level = overrideLevel, + }; + return new RuleRecord( runId: "run-001", ruleId: ResourceId.Parse(ruleId), @@ -449,7 +504,11 @@ private static RuleRecord GetFail(string ruleRef = "rid-002", SeverityLevel leve { ["field-data"] = "Custom field data" }, - level: level, + @default: new RuleProperties + { + Level = level + }, + @override: ruleOverride, extent: null, outcome: RuleOutcome.Fail, reason: RuleOutcomeReason.Processed diff --git a/tests/PSRule.Tests/PSRule.Options.Tests.ps1 b/tests/PSRule.Tests/PSRule.Options.Tests.ps1 index a53d030cb3..8bf770cf50 100644 --- a/tests/PSRule.Tests/PSRule.Options.Tests.ps1 +++ b/tests/PSRule.Tests/PSRule.Options.Tests.ps1 @@ -2145,6 +2145,39 @@ Describe 'New-PSRuleOption' -Tag 'Option','New-PSRuleOption' { } } + Context 'Read Override.Level' { + It 'from default' { + $option = New-PSRuleOption -Default; + $option.Override.Level | Should -BeNullOrEmpty; + } + + It 'from Hashtable' { + $option = New-PSRuleOption -Option @{ 'Override.Level.rule1' = 'Information' }; + $option.Override.Level['rule1'] | Should -Be 'Information'; + } + + It 'from YAML' { + $option = New-PSRuleOption -Option (Join-Path -Path $here -ChildPath 'PSRule.Tests.yml'); + $option.Override.Level['rule1'] | Should -Be 'Information'; + } + + It 'from Environment' { + try { + $Env:PSRULE_OVERRIDE_LEVEL_RULE1 = 'Information'; + $option = New-PSRuleOption; + $option.Override.Level['rule1'] | Should -Be 'Information'; + } + finally { + Remove-Item 'Env:PSRULE_OVERRIDE_LEVEL_RULE1' -Force; + } + } + + It 'from parameter' { + $option = New-PSRuleOption -OverrideLevel @{ rule1 = 'Information' } -Path $emptyOptionsFilePath; + $option.Override.Level['rule1'] | Should -Be 'Information'; + } + } + Context 'Read Repository.BaseRef' { It 'from default' { $option = New-PSRuleOption -Default; diff --git a/tests/PSRule.Tests/PSRule.Tests.csproj b/tests/PSRule.Tests/PSRule.Tests.csproj index c5d3066847..9ddb5424fd 100644 --- a/tests/PSRule.Tests/PSRule.Tests.csproj +++ b/tests/PSRule.Tests/PSRule.Tests.csproj @@ -149,6 +149,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/tests/PSRule.Tests/PSRule.Tests.yml b/tests/PSRule.Tests/PSRule.Tests.yml index 2a882313ab..9d36a8ae0e 100644 --- a/tests/PSRule.Tests/PSRule.Tests.yml +++ b/tests/PSRule.Tests/PSRule.Tests.yml @@ -22,6 +22,11 @@ emitter: include: - .csproj +override: + level: + rule1: Information + Group.*: Error + # Configure baseline rule: include: diff --git a/tests/PSRule.Tests/PSRule.Tests14.yml b/tests/PSRule.Tests/PSRule.Tests14.yml index 5df7c52d1a..ce50036c9c 100644 --- a/tests/PSRule.Tests/PSRule.Tests14.yml +++ b/tests/PSRule.Tests/PSRule.Tests14.yml @@ -2,14 +2,14 @@ suppression: YAML.RuleWithAlias1: - - 'TestObject1' + - 'TestObject1' PSRZZ.0001: - - 'TestObject1' + - 'TestObject1' JSON.AlternativeName: - - 'TestObject2' + - 'TestObject2' YAML.AlternativeName: - - 'TestObject2' + - 'TestObject2' .\PSRZZ.0003: - - 'TestObject3' + - 'TestObject3' '.\PS.AlternativeName': - - 'TestObject3' + - 'TestObject3' diff --git a/tests/PSRule.Tests/PSRule.Tests17.yml b/tests/PSRule.Tests/PSRule.Tests17.yml new file mode 100644 index 0000000000..5df42b9b23 --- /dev/null +++ b/tests/PSRule.Tests/PSRule.Tests17.yml @@ -0,0 +1,4 @@ +# These are options for unit tests + +override: + level: diff --git a/tests/PSRule.Tests/PSRule.Tests2.yml b/tests/PSRule.Tests/PSRule.Tests2.yml index 5298583a4a..b2dc669e26 100644 --- a/tests/PSRule.Tests/PSRule.Tests2.yml +++ b/tests/PSRule.Tests/PSRule.Tests2.yml @@ -3,39 +3,41 @@ # Configure binding binding: targetName: - - ResourceName - - AlternateName + - ResourceName + - AlternateName targetType: - - ResourceType - - kind + - ResourceType + - kind # Configure conventions convention: include: - - 'Convention1' - - 'Convention2' + - 'Convention1' + - 'Convention2' # Configure input input: targetType: - - virtualMachine - - virtualNetwork + - virtualMachine + - virtualNetwork # Configure output output: banner: 1 culture: - - 'en-CC' - - 'en-DD' + - 'en-CC' + - 'en-DD' + +override: {} # Configure required module versions -requires: { } +requires: {} # Configure baseline rule: include: - - rule1 - - rule2 + - rule1 + - rule2 exclude: - - rule3 - - rule4 + - rule3 + - rule4 diff --git a/tests/PSRule.Tests/PSRuleOptionTests.cs b/tests/PSRule.Tests/PSRuleOptionTests.cs index f388ae2dce..54ab2696c9 100644 --- a/tests/PSRule.Tests/PSRuleOptionTests.cs +++ b/tests/PSRule.Tests/PSRuleOptionTests.cs @@ -5,6 +5,7 @@ using System.IO; using System.Management.Automation; using PSRule.Configuration; +using PSRule.Definitions.Rules; using PSRule.Pipeline; namespace PSRule; @@ -87,6 +88,28 @@ public void GetBaselineGroupFromYaml() Assert.Equal(new string[] { ".\\TestBaseline1" }, latest); } + [Fact] + public void FromFile_WhenOverrideIsDefined_ShouldDeserializeLevel() + { + var option = GetOption(); + var actual = option.Override.Level; + Assert.True(actual.TryGetValue("rule1", out var level)); + Assert.Equal(SeverityLevel.Information, level); + + Assert.True(actual.TryGetValue("Group.*", out level)); + Assert.Equal(SeverityLevel.Error, level); + } + + [Theory] + [InlineData("PSRule.Tests2.yml")] + [InlineData("PSRule.Tests17.yml")] + public void FromFile_WhenOverrideIsPartiallyDefined_ShouldDeserializeWithoutError(string path) + { + var option = GetOption(GetSourcePath(path)); + var actual = option.Override.Level; + Assert.Null(actual); + } + #region Helper methods private Runtime.Configuration GetConfigurationHelper(PSRuleOption option) @@ -108,7 +131,7 @@ private static Source[] GetSource() protected sealed override PSRuleOption GetOption() { - return PSRuleOption.FromFile(GetSourcePath("PSRule.Tests.yml")); + return GetOption(GetSourcePath("PSRule.Tests.yml")); } #endregion Helper methods diff --git a/tests/PSRule.Tests/PipelineTests.cs b/tests/PSRule.Tests/PipelineTests.cs index 9c891b4e58..ba4988e035 100644 --- a/tests/PSRule.Tests/PipelineTests.cs +++ b/tests/PSRule.Tests/PipelineTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Globalization; using System.IO; using System.Linq; @@ -9,6 +8,7 @@ using System.Threading; using Newtonsoft.Json.Linq; using PSRule.Configuration; +using PSRule.Definitions.Rules; using PSRule.Options; using PSRule.Pipeline; using PSRule.Resources; @@ -80,9 +80,15 @@ public void InvokePipelineWithJObject() var actual = (writer.Output[0] as InvokeResult).AsRecord().FirstOrDefault(); Assert.Equal(RuleOutcome.Pass, actual.Outcome); + Assert.Equal(SeverityLevel.Error, actual.Default.Level); + Assert.Equal(SeverityLevel.Warning, actual.Override.Level); + Assert.Equal(SeverityLevel.Warning, actual.Level); actual = (writer.Output[1] as InvokeResult).AsRecord().FirstOrDefault(); Assert.Equal(RuleOutcome.Fail, actual.Outcome); + Assert.Equal(SeverityLevel.Error, actual.Default.Level); + Assert.Equal(SeverityLevel.Warning, actual.Override.Level); + Assert.Equal(SeverityLevel.Warning, actual.Level); Assert.Equal("Name", actual.Detail.Reason.First().Path); Assert.Equal("resources[1].Name", actual.Detail.Reason.First().FullPath); } @@ -351,14 +357,11 @@ private static PSRuleOption GetOption(string path = null, ExecutionActionPrefere var option = path == null ? new PSRuleOption() : PSRuleOption.FromFile(path); option.Rule.IncludeLocal = false; option.Execution.RuleExcluded = ruleExcludedAction; + option.Override.Level ??= []; + option.Override.Level.Add("ScriptReasonTest", SeverityLevel.Warning); return option; } - private static string GetSourcePath(string fileName) - { - return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileName); - } - private static PSObject GetTestObject() { var info = new PSObject(); diff --git a/tests/PSRule.Tests/ResourceMapTests.cs b/tests/PSRule.Tests/ResourceMapTests.cs new file mode 100644 index 0000000000..8a21670435 --- /dev/null +++ b/tests/PSRule.Tests/ResourceMapTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using PSRule.Data; +using PSRule.Definitions.Rules; + +namespace PSRule; + +public sealed class ResourceMapTests +{ + [Fact] + public void Get() + { + var map = new WildcardMap(); + Assert.False(map.TryGetValue("Rule1", out _)); + + map = new WildcardMap(new Dictionary + { + { "Rule1", SeverityLevel.Warning }, + { "Rule2", SeverityLevel.Error }, + { "Rules.1", SeverityLevel.Error }, + { "Rules.*", SeverityLevel.Information }, + { "Rules.z*", SeverityLevel.Warning }, + { "Rules.2", SeverityLevel.Error }, + }); + + Assert.True(map.TryGetValue("Rule1", out var level)); + Assert.Equal(SeverityLevel.Warning, level); + Assert.True(map.TryGetValue("Rule2", out level)); + Assert.Equal(SeverityLevel.Error, level); + Assert.True(map.TryGetValue("Rules.1", out level)); + Assert.Equal(SeverityLevel.Error, level); + Assert.True(map.TryGetValue("Rules.2", out level)); + Assert.Equal(SeverityLevel.Error, level); + Assert.True(map.TryGetValue("Rules.3", out level)); + Assert.Equal(SeverityLevel.Information, level); + Assert.True(map.TryGetValue("Rules.zzzz", out level)); + Assert.Equal(SeverityLevel.Warning, level); + Assert.False(map.TryGetValue("Rules.", out _)); + Assert.False(map.TryGetValue("Rules", out _)); + } +}