diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index 73d69c1b5c..8b75d2883a 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -46,6 +46,9 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers - Removed the `If` Premium SKU. - Added check for Premium SKU. - Bumped rule set to `2024_06` +- General improvements: + - Added support for `split` and `concat` functions during policy export by @BernieWhite. + [#2851](https://github.com/Azure/PSRule.Rules.Azure/issues/2851) - Engineering: - Bump xunit to v2.8.1. [#2892](https://github.com/Azure/PSRule.Rules.Azure/pull/2892) diff --git a/src/PSRule.Rules.Azure/Data/Policy/PolicyAssignmentVisitor.cs b/src/PSRule.Rules.Azure/Data/Policy/PolicyAssignmentVisitor.cs index 5f25de117c..fb369e4e76 100644 --- a/src/PSRule.Rules.Azure/Data/Policy/PolicyAssignmentVisitor.cs +++ b/src/PSRule.Rules.Azure/Data/Policy/PolicyAssignmentVisitor.cs @@ -76,6 +76,11 @@ internal abstract class PolicyAssignmentVisitor : ResourceManagerVisitor private const string PROPERTY_HASVALUE = "hasValue"; private const string PROPERTY_EMPTY = "empty"; private const string PROPERTY_LENGTH = "length"; + private const string PROPERTY_CONCAT = "concat"; + private const string PROPERTY_SPLIT = "split"; + private const string PROPERTY_STRING = "string"; + private const string PROPERTY_INTEGER = "integer"; + private const string PROPERTY_DELIMITER = "delimiter"; private const string EFFECT_DISABLED = "Disabled"; private const string EFFECT_AUDITIFNOTEXISTS = "AuditIfNotExists"; @@ -1291,7 +1296,7 @@ private static JObject VisitValueExpression(PolicyAssignmentContext context, JOb } // Handle [field('string')] - else if (tokens.HasFieldTokens()) + else if (tokens.HasFieldTokens() && !tokens.HasPolicyRuntimeTokens()) { condition = VisitFieldTokens(context, condition, tokens); } @@ -1331,14 +1336,14 @@ private static JObject VisitFieldTokens(PolicyAssignmentContext context, JObject else if (tokens.ConsumeFunction(PROPERTY_IF) && tokens.TryTokenType(ExpressionTokenType.GroupStart, out _)) { - var orginal = condition; + var original = condition; // Condition var leftCondition = VisitFieldTokens(context, new JObject(), tokens); var rightCondition = ReverseCondition(Clone(leftCondition)); - var leftEvaluation = VisitFieldTokens(context, Clone(orginal), tokens); - var rightEvaluation = VisitFieldTokens(context, Clone(orginal), tokens); + var leftEvaluation = VisitFieldTokens(context, Clone(original), tokens); + var rightEvaluation = VisitFieldTokens(context, Clone(original), tokens); var left = new JObject { @@ -1498,6 +1503,8 @@ private static JObject VisitFieldTokens(PolicyAssignmentContext context, JObject private static JObject VisitRuntimeTokens(PolicyAssignmentContext context, TokenStream tokens) { var o = VisitRuntimeToken(context, tokens); + if (tokens.Count > 0) throw new NotImplementedException(string.Format(Thread.CurrentThread.CurrentCulture, PSRuleResources.PolicyRuntimeTokenNotProcessed, tokens.AsString())); + return o == null ? null : new JObject { { DOLLAR, o } @@ -1524,7 +1531,7 @@ private static JObject VisitRuntimeToken(PolicyAssignmentContext context, TokenS } else if (tokens.ConsumeFunction(FUNCTION_CURRENT) && tokens.Skip(ExpressionTokenType.GroupStart)) { - var fieldTarget = ""; + var fieldTarget = string.Empty; if (tokens.TryTokenType(ExpressionTokenType.String, out var current)) { fieldTarget = current.Content; @@ -1538,6 +1545,72 @@ private static JObject VisitRuntimeToken(PolicyAssignmentContext context, TokenS tokens.Skip(ExpressionTokenType.GroupEnd); return o; } + else if (tokens.ConsumeFunction(PROPERTY_CONCAT) && + tokens.TryTokenType(ExpressionTokenType.GroupStart, out _)) + { + var items = new JArray(); + + while (tokens.Current.Type == ExpressionTokenType.Element || + tokens.Current.Type == ExpressionTokenType.String) + { + var child = VisitRuntimeToken(context, tokens); + items.Add(child); + } + var o = new JObject + { + [PROPERTY_CONCAT] = items + }; + tokens.TryTokenType(ExpressionTokenType.GroupEnd, out _); + return o; + } + else if (tokens.ConsumeFunction(PROPERTY_SPLIT) && + tokens.TryTokenType(ExpressionTokenType.GroupStart, out _)) + { + var child = VisitRuntimeToken(context, tokens); + + var delimiter = new JArray(); + if (tokens.ConsumeString(out var d)) + { + delimiter.Add(d); + } + + var o = new JObject + { + { PROPERTY_SPLIT, child }, + { PROPERTY_DELIMITER, delimiter } + }; + tokens.TryTokenType(ExpressionTokenType.GroupEnd, out _); + return o; + } + else if (tokens.ConsumeFunction(PROPERTY_FIELD) && + tokens.TryTokenType(ExpressionTokenType.GroupStart, out _) && + tokens.ConsumeString(out var field)) + { + field = context.TryPolicyAliasPath(field, out var aliasPath) ? TrimFieldName(context, aliasPath) : field; + + var o = new JObject + { + { PROPERTY_PATH, field } + }; + tokens.TryTokenType(ExpressionTokenType.GroupEnd, out _); + return o; + } + else if (tokens.ConsumeString(out var s)) + { + var o = new JObject + { + { PROPERTY_STRING, s } + }; + return o; + } + else if (tokens.ConsumeInteger(out var i)) + { + var o = new JObject + { + { PROPERTY_INTEGER, i } + }; + return o; + } return null; } diff --git a/src/PSRule.Rules.Azure/Data/Template/ExpressionToken.cs b/src/PSRule.Rules.Azure/Data/Template/ExpressionToken.cs new file mode 100644 index 0000000000..82db9709a8 --- /dev/null +++ b/src/PSRule.Rules.Azure/Data/Template/ExpressionToken.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; + +namespace PSRule.Rules.Azure.Data.Template +{ + /// + /// An individual expression token. + /// + [DebuggerDisplay("Type = {Type}, Content = {Content}")] + internal sealed class ExpressionToken + { + internal readonly ExpressionTokenType Type; + internal readonly long Value; + internal readonly string Content; + + internal ExpressionToken(ExpressionTokenType type, string content) + { + Type = type; + Content = content; + } + + internal ExpressionToken(long value) + { + Type = ExpressionTokenType.Numeric; + Value = value; + } + } +} diff --git a/src/PSRule.Rules.Azure/Data/Template/ExpressionTokenType.cs b/src/PSRule.Rules.Azure/Data/Template/ExpressionTokenType.cs new file mode 100644 index 0000000000..7afe503854 --- /dev/null +++ b/src/PSRule.Rules.Azure/Data/Template/ExpressionTokenType.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PSRule.Rules.Azure.Data.Template +{ + /// + /// The available token types used for Azure Resource Manager expressions. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "Represents standard type.")] + public enum ExpressionTokenType : byte + { + /// + /// Null token. + /// + None, + + /// + /// A function name. + /// + Element, + + /// + /// A property .property_name. + /// + Property, + + /// + /// A string literal. + /// + String, + + /// + /// A numeric literal. + /// + Numeric, + + /// + /// Start a grouping '('. + /// + GroupStart, + + /// + /// End a grouping ')'. + /// + GroupEnd, + + /// + /// Start an index '['. + /// + IndexStart, + + /// + /// End an index ']'. + /// + IndexEnd + } +} diff --git a/src/PSRule.Rules.Azure/Data/Template/TokenStream.cs b/src/PSRule.Rules.Azure/Data/Template/TokenStream.cs index 8534ee0a02..ade9910686 100644 --- a/src/PSRule.Rules.Azure/Data/Template/TokenStream.cs +++ b/src/PSRule.Rules.Azure/Data/Template/TokenStream.cs @@ -1,224 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Text; namespace PSRule.Rules.Azure.Data.Template { - /// - /// The available token types used for Azure Resource Manager expressions. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "Represents standard type.")] - public enum ExpressionTokenType : byte - { - /// - /// Null token. - /// - None, - - /// - /// A function name. - /// - Element, - - /// - /// A property .property_name. - /// - Property, - - /// - /// A string literal. - /// - String, - - /// - /// A numeric literal. - /// - Numeric, - - /// - /// Start a grouping '('. - /// - GroupStart, - - /// - /// End a grouping ')'. - /// - GroupEnd, - - /// - /// Start an index '['. - /// - IndexStart, - - /// - /// End an index ']'. - /// - IndexEnd - } - - /// - /// An individual expression token. - /// - [DebuggerDisplay("Type = {Type}, Content = {Content}")] - internal sealed class ExpressionToken - { - internal readonly ExpressionTokenType Type; - internal readonly long Value; - internal readonly string Content; - - internal ExpressionToken(ExpressionTokenType type, string content) - { - Type = type; - Content = content; - } - - internal ExpressionToken(long value) - { - Type = ExpressionTokenType.Numeric; - Value = value; - } - } - - /// - /// Add an expression token to a token stream. - /// - internal static class TokenStreamExtensions - { - internal static void Function(this TokenStream stream, string functionName) - { - stream.Add(new ExpressionToken(ExpressionTokenType.Element, functionName)); - } - - internal static void Numeric(this TokenStream stream, long value) - { - stream.Add(new ExpressionToken(value)); - } - - internal static void String(this TokenStream stream, string content) - { - stream.Add(new ExpressionToken(ExpressionTokenType.String, content)); - } - - internal static void Property(this TokenStream stream, string propertyName) - { - stream.Add(new ExpressionToken(ExpressionTokenType.Property, propertyName)); - } - - internal static void GroupStart(this TokenStream stream) - { - stream.Add(new ExpressionToken(ExpressionTokenType.GroupStart, null)); - } - - internal static void GroupEnd(this TokenStream stream) - { - stream.Add(new ExpressionToken(ExpressionTokenType.GroupEnd, null)); - } - - internal static void IndexStart(this TokenStream stream) - { - stream.Add(new ExpressionToken(ExpressionTokenType.IndexStart, null)); - } - - internal static void IndexEnd(this TokenStream stream) - { - stream.Add(new ExpressionToken(ExpressionTokenType.IndexEnd, null)); - } - - internal static bool ConsumeFunction(this TokenStream stream, string name) - { - if (stream == null || - stream.Count == 0 || - stream.Current.Type != ExpressionTokenType.Element || - !string.Equals(stream.Current.Content, name, StringComparison.OrdinalIgnoreCase)) - return false; - - stream.Pop(); - return true; - } - - internal static bool ConsumePropertyName(this TokenStream stream, string propertyName) - { - if (stream == null || - stream.Count == 0 || - stream.Current.Type != ExpressionTokenType.Property || - !string.Equals(stream.Current.Content, propertyName, StringComparison.OrdinalIgnoreCase)) - return false; - - stream.Pop(); - return true; - } - - internal static bool ConsumeString(this TokenStream stream, string s) - { - if (stream == null || - stream.Count == 0 || - stream.Current.Type != ExpressionTokenType.String || - !string.Equals(stream.Current.Content, s, StringComparison.OrdinalIgnoreCase)) - return false; - - stream.Pop(); - return true; - } - - internal static bool ConsumeString(this TokenStream stream, out string s) - { - s = null; - if (stream == null || - stream.Count == 0 || - stream.Current.Type != ExpressionTokenType.String) - return false; - - s = stream.Current.Content; - stream.Pop(); - return true; - } - - internal static bool ConsumeInteger(this TokenStream stream, out int? i) - { - i = null; - if (stream == null || - stream.Count == 0 || - stream.Current.Type != ExpressionTokenType.Numeric) - return false; - - i = (int)stream.Current.Value; - stream.Pop(); - return true; - } - - internal static bool ConsumeGroup(this TokenStream stream) - { - if (stream == null || - stream.Count == 0 || - stream.Current.Type != ExpressionTokenType.GroupStart) - return false; - - stream.SkipGroup(); - return true; - } - - internal static bool HasFieldTokens(this TokenStream stream) - { - return stream.ToArray().Any(t => - t.Type == ExpressionTokenType.Element && - string.Equals("field", t.Content, StringComparison.OrdinalIgnoreCase) - ); - } - - internal static bool HasPolicyRuntimeTokens(this TokenStream stream) - { - return stream.ToArray().Any(t => - t.Type == ExpressionTokenType.Element && - string.Equals("current", t.Content, StringComparison.OrdinalIgnoreCase) - ); - } - } - /// /// A stream of template expression tokens. /// diff --git a/src/PSRule.Rules.Azure/Data/Template/TokenStreamExtensions.cs b/src/PSRule.Rules.Azure/Data/Template/TokenStreamExtensions.cs new file mode 100644 index 0000000000..4c6e8a2286 --- /dev/null +++ b/src/PSRule.Rules.Azure/Data/Template/TokenStreamExtensions.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Linq; + +namespace PSRule.Rules.Azure.Data.Template +{ + /// + /// Add an expression token to a token stream. + /// + internal static class TokenStreamExtensions + { + private const string TOKEN_CURRENT = "current"; + private const string TOKEN_FIELD = "field"; + private const string TOKEN_CONCAT = "concat"; + private const string TOKEN_SPLIT = "split"; + + internal static void Function(this TokenStream stream, string functionName) + { + stream.Add(new ExpressionToken(ExpressionTokenType.Element, functionName)); + } + + internal static void Numeric(this TokenStream stream, long value) + { + stream.Add(new ExpressionToken(value)); + } + + internal static void String(this TokenStream stream, string content) + { + stream.Add(new ExpressionToken(ExpressionTokenType.String, content)); + } + + internal static void Property(this TokenStream stream, string propertyName) + { + stream.Add(new ExpressionToken(ExpressionTokenType.Property, propertyName)); + } + + internal static void GroupStart(this TokenStream stream) + { + stream.Add(new ExpressionToken(ExpressionTokenType.GroupStart, null)); + } + + internal static void GroupEnd(this TokenStream stream) + { + stream.Add(new ExpressionToken(ExpressionTokenType.GroupEnd, null)); + } + + internal static void IndexStart(this TokenStream stream) + { + stream.Add(new ExpressionToken(ExpressionTokenType.IndexStart, null)); + } + + internal static void IndexEnd(this TokenStream stream) + { + stream.Add(new ExpressionToken(ExpressionTokenType.IndexEnd, null)); + } + + internal static bool ConsumeFunction(this TokenStream stream, string name) + { + if (stream == null || + stream.Count == 0 || + stream.Current.Type != ExpressionTokenType.Element || + !string.Equals(stream.Current.Content, name, StringComparison.OrdinalIgnoreCase)) + return false; + + stream.Pop(); + return true; + } + + internal static bool ConsumePropertyName(this TokenStream stream, string propertyName) + { + if (stream == null || + stream.Count == 0 || + stream.Current.Type != ExpressionTokenType.Property || + !string.Equals(stream.Current.Content, propertyName, StringComparison.OrdinalIgnoreCase)) + return false; + + stream.Pop(); + return true; + } + + internal static bool ConsumeString(this TokenStream stream, string s) + { + if (stream == null || + stream.Count == 0 || + stream.Current.Type != ExpressionTokenType.String || + !string.Equals(stream.Current.Content, s, StringComparison.OrdinalIgnoreCase)) + return false; + + stream.Pop(); + return true; + } + + internal static bool ConsumeString(this TokenStream stream, out string s) + { + s = null; + if (stream == null || + stream.Count == 0 || + stream.Current.Type != ExpressionTokenType.String) + return false; + + s = stream.Current.Content; + stream.Pop(); + return true; + } + + internal static bool ConsumeInteger(this TokenStream stream, out int? i) + { + i = null; + if (stream == null || + stream.Count == 0 || + stream.Current.Type != ExpressionTokenType.Numeric) + return false; + + i = (int)stream.Current.Value; + stream.Pop(); + return true; + } + + internal static bool ConsumeGroup(this TokenStream stream) + { + if (stream == null || + stream.Count == 0 || + stream.Current.Type != ExpressionTokenType.GroupStart) + return false; + + stream.SkipGroup(); + return true; + } + + internal static bool HasFieldTokens(this TokenStream stream) + { + return stream.ToArray().Any(t => + t.Type == ExpressionTokenType.Element && + string.Equals(TOKEN_FIELD, t.Content, StringComparison.OrdinalIgnoreCase) + ); + } + + internal static bool HasPolicyRuntimeTokens(this TokenStream stream) + { + return stream.ToArray().Any(t => + t.Type == ExpressionTokenType.Element && + ( + string.Equals(TOKEN_CURRENT, t.Content, StringComparison.OrdinalIgnoreCase) || + string.Equals(TOKEN_CONCAT, t.Content, StringComparison.OrdinalIgnoreCase) || + string.Equals(TOKEN_SPLIT, t.Content, StringComparison.OrdinalIgnoreCase) + ) + ); + } + } +} diff --git a/src/PSRule.Rules.Azure/Resources/PSRuleResources.Designer.cs b/src/PSRule.Rules.Azure/Resources/PSRuleResources.Designer.cs index d3080776a0..f6e217c20a 100644 --- a/src/PSRule.Rules.Azure/Resources/PSRuleResources.Designer.cs +++ b/src/PSRule.Rules.Azure/Resources/PSRuleResources.Designer.cs @@ -411,6 +411,15 @@ internal static string PolicyIgnoreNotApplicable { } } + /// + /// Looks up a localized string similar to Failed to process all runtime tokens: {0}. + /// + internal static string PolicyRuntimeTokenNotProcessed { + get { + return ResourceManager.GetString("PolicyRuntimeTokenNotProcessed", resourceCulture); + } + } + /// /// Looks up a localized string similar to The language expression property '{0}' doesn't exist.. /// diff --git a/src/PSRule.Rules.Azure/Resources/PSRuleResources.resx b/src/PSRule.Rules.Azure/Resources/PSRuleResources.resx index 28b16ed3a8..89dea1fef7 100644 --- a/src/PSRule.Rules.Azure/Resources/PSRuleResources.resx +++ b/src/PSRule.Rules.Azure/Resources/PSRuleResources.resx @@ -241,6 +241,9 @@ Policy definition has been ignored because it is not applicable to Infrastructure as Code: {0} + + Failed to process all runtime tokens: {0} + The language expression property '{0}' doesn't exist. Occurs when a property that don't exist is referenced. diff --git a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj index 500ea555dc..721e187474 100644 --- a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj +++ b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj @@ -50,6 +50,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/tests/PSRule.Rules.Azure.Tests/Policy.assignment.8.json b/tests/PSRule.Rules.Azure.Tests/Policy.assignment.8.json new file mode 100644 index 0000000000..29a28d65e1 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/Policy.assignment.8.json @@ -0,0 +1,93 @@ +[ + { + "Identity": null, + "Location": null, + "Name": "assignment.8", + "ResourceId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/policyAssignments/assignment.8", + "ResourceName": "assignment.8", + "ResourceGroupName": null, + "ResourceType": "Microsoft.Authorization/policyAssignments", + "SubscriptionId": "00000000-0000-0000-0000-000000000000", + "Sku": null, + "PolicyAssignmentId": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/policyAssignments/assignment.8", + "Properties": { + "Scope": "/subscriptions/00000000-0000-0000-0000-000000000000", + "NotScopes": [], + "DisplayName": "Prevent cross-subscription private endpoints.", + "Description": null, + "Metadata": { + "assignedBy": "", + "parameterScopes": {}, + "createdBy": "", + "createdOn": "", + "updatedBy": null, + "updatedOn": null + }, + "EnforcementMode": 1, + "PolicyDefinitionId": "/providers/Microsoft.Management/managementGroups/mg-01/providers/Microsoft.Authorization/policyDefinitions/00000000-0000-0000-0000-000000000000", + "Parameters": {}, + "NonComplianceMessages": [] + }, + "PolicyDefinitions": [ + { + "Name": "00000000-0000-0000-0000-000000000001", + "ResourceId": "/providers/Microsoft.Management/managementGroups/mg-01/providers/Microsoft.Authorization/policyDefinitions/00000000-0000-0000-0000-000000000001", + "ResourceName": "00000000-0000-0000-0000-000000000001", + "ResourceType": "Microsoft.Authorization/policyDefinitions", + "SubscriptionId": null, + "Properties": { + "Description": "Example", + "DisplayName": "Prevent cross-subscription private endpoints.", + "Metadata": { + "version": "0.0.1", + "category": "Storage" + }, + "Mode": "Indexed", + "Parameters": { + "effect": { + "type": "String", + "metadata": { + "description": "Enable or disable the execution of the policy", + "displayName": "Effect" + }, + "allowedValues": [ + "Audit", + "Deny", + "Disabled" + ], + "defaultValue": "Deny" + } + }, + "PolicyRule": { + "if": { + "allOf": [ + { + "equals": "Microsoft.Storage/storageAccounts/privateEndpointConnections", + "field": "type" + }, + { + "anyOf": [ + { + "exists": false, + "field": "Microsoft.Storage/storageAccounts/privateEndpointConnections/privateEndpoint.id" + }, + { + "notEquals": "[subscription().subscriptionId]", + "value": "[split(concat(field('Microsoft.Storage/storageAccounts/privateEndpointConnections/privateEndpoint.id'), '//'), '/')]" + } + ] + } + ] + }, + "then": { + "effect": "[parameters('effect')]" + } + }, + "PolicyType": 1 + }, + "PolicyDefinitionId": "/providers/Microsoft.Management/managementGroups/mg-01/providers/Microsoft.Authorization/policyDefinitions/00000000-0000-0000-0000-000000000001" + } + ], + "exemptions": [] + } +] diff --git a/tests/PSRule.Rules.Azure.Tests/PolicyAssignmentVisitorTests.cs b/tests/PSRule.Rules.Azure.Tests/PolicyAssignmentVisitorTests.cs index 1dfe50f6df..8a2da7a088 100644 --- a/tests/PSRule.Rules.Azure.Tests/PolicyAssignmentVisitorTests.cs +++ b/tests/PSRule.Rules.Azure.Tests/PolicyAssignmentVisitorTests.cs @@ -269,7 +269,7 @@ public void Expand_field_with_less() } [Fact] - public void Expand_field_with_length() + public void Visit_ShouldComplete_WhenLengthField() { var context = new PolicyAssignmentContext(GetContext()); var visitor = new PolicyAssignmentDataExportVisitor(); @@ -293,6 +293,25 @@ public void Expand_field_with_length() Assert.Equal("{\"anyOf\":[{\"exists\":false,\"field\":\"properties.networkAcls.defaultAction\"},{\"equals\":\"Allow\",\"field\":\"properties.networkAcls.defaultAction\"},{\"exists\":false,\"field\":\"properties.networkAcls.virtualNetworkRules\"},{\"field\":\"properties.networkAcls.virtualNetworkRules\",\"notCount\":0},{\"exists\":false,\"field\":\"properties.networkAcls.ipRules\"},{\"field\":\"properties.networkAcls.ipRules\",\"notCount\":0}]}", actual.Where.ToString(Formatting.None)); } + [Fact] + public void Visit_ShouldComplete_WhenSplitConcatField() + { + var context = new PolicyAssignmentContext(GetContext()); + var visitor = new PolicyAssignmentDataExportVisitor(); + foreach (var assignment in GetAssignmentData("Policy.assignment.8.json").Where(a => a["Name"].Value() == "assignment.8")) + visitor.Visit(context, assignment); + + var definitions = context.GetDefinitions(); + Assert.NotNull(definitions); + Assert.Single(definitions); + + var actual = definitions.FirstOrDefault(); + Assert.Single(actual.Types); + Assert.Equal("Microsoft.Storage/storageAccounts/privateEndpointConnections", actual.Types[0]); + var temp = actual.Where.ToString(Formatting.None); + Assert.Equal("{\"anyOf\":[{\"exists\":false,\"field\":\"properties.privateEndpoint.id\"},{\"notEquals\":\"ffffffff-ffff-ffff-ffff-ffffffffffff\",\"value\":{\"$\":{\"split\":{\"concat\":[{\"path\":\"properties.privateEndpoint.id\"},{\"string\":\"//\"}]},\"delimiter\":[\"/\"]}}}]}", temp); + } + #region Helper methods private static PipelineContext GetContext(PSRuleOption option = null)