diff --git a/docs/CHANGELOG-v1.md b/docs/CHANGELOG-v1.md index 94e7e44d028..893a6b6a322 100644 --- a/docs/CHANGELOG-v1.md +++ b/docs/CHANGELOG-v1.md @@ -29,6 +29,12 @@ See [upgrade notes][1] for helpful information when upgrading from previous vers ## Unreleased +What's changed since pre-release v1.40.0-B0147: + +- Bug fixes: + - Fixed evaluation of APIM policies when using embedded C# with quotes by #BernieWhite. + [#3184](https://github.com/Azure/PSRule.Rules.Azure/issues/3184) + ## v1.40.0-B0147 (pre-release) What's changed since pre-release v1.40.0-B0103: diff --git a/src/PSRule.Rules.Azure/Data/APIM/APIMPolicyReader.cs b/src/PSRule.Rules.Azure/Data/APIM/APIMPolicyReader.cs new file mode 100644 index 00000000000..e5a8f1408ca --- /dev/null +++ b/src/PSRule.Rules.Azure/Data/APIM/APIMPolicyReader.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Xml; +using System.Text.RegularExpressions; +using System.IO; + +namespace PSRule.Rules.Azure.Data.APIM; + +/// +/// A reader for APIM policy files. +/// +internal static class APIMPolicyReader +{ + // Create a regex pattern to match the `="@(` `)"` pairs. + private static readonly Regex _Pattern = new(@"(?<=\=""\@\().*?(?=\)"")", RegexOptions.Singleline | RegexOptions.Compiled); + + /// + /// Read the content of the policy file as XML. + /// + /// + /// This method automatically escapes quotes within policy expressions to ensure the XML is well-formed. + /// + public static XmlReader ReadContent(string content) + { + // For each match replace `"` characters with `"`. + foreach (Match match in _Pattern.Matches(content)) + { + content = content.Replace(match.Value, match.Value.Replace("\"", """)); + } + + return XmlReader.Create(new StringReader(content)); + } +} diff --git a/src/PSRule.Rules.Azure/Runtime/Helper.cs b/src/PSRule.Rules.Azure/Runtime/Helper.cs index 8aa34b0d921..7e00f0fc9f5 100644 --- a/src/PSRule.Rules.Azure/Runtime/Helper.cs +++ b/src/PSRule.Rules.Azure/Runtime/Helper.cs @@ -5,8 +5,10 @@ using System.IO; using System.Linq; using System.Management.Automation; +using System.Xml; using PSRule.Rules.Azure.Configuration; using PSRule.Rules.Azure.Data; +using PSRule.Rules.Azure.Data.APIM; using PSRule.Rules.Azure.Data.Bicep; using PSRule.Rules.Azure.Data.Network; using PSRule.Rules.Azure.Data.Template; @@ -206,6 +208,17 @@ public static string GetSubResourceName(string resourceName) return parts[parts.Length - 1]; } + /// + /// Load a APIM policy as an XML document. + /// + public static XmlDocument GetAPIMPolicyDocument(string content) + { + using var reader = APIMPolicyReader.ReadContent(content); + var doc = new XmlDocument(); + doc.Load(reader); + return doc; + } + #region Helper methods private static PSObject[] GetTemplateResources(string templateFile, string parameterFile, PipelineContext context) diff --git a/src/PSRule.Rules.Azure/rules/Azure.APIM.Rule.ps1 b/src/PSRule.Rules.Azure/rules/Azure.APIM.Rule.ps1 index 46596b5bbc0..eeae178ccf1 100644 --- a/src/PSRule.Rules.Azure/rules/Azure.APIM.Rule.ps1 +++ b/src/PSRule.Rules.Azure/rules/Azure.APIM.Rule.ps1 @@ -383,7 +383,7 @@ function global:GetAPIMPolicyNode { } $policies | ForEach-Object { if (!($IgnoreGlobal -and $_.type -eq 'Microsoft.ApiManagement/service/policies') -and $_.properties.format -in 'rawxml', 'xml' -and $_.properties.value) { - $xml = [Xml]$_.properties.value + $xml = [PSRule.Rules.Azure.Runtime.Helper]::GetAPIMPolicyDocument($_.properties.value) $xml.SelectNodes("//${Node}") } } diff --git a/tests/PSRule.Rules.Azure.Tests/APIM/APIMPolicyTests.cs b/tests/PSRule.Rules.Azure.Tests/APIM/APIMPolicyTests.cs new file mode 100644 index 00000000000..b4cdcb1894a --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/APIM/APIMPolicyTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Xml; +using PSRule.Rules.Azure.Data.APIM; + +namespace PSRule.Rules.Azure.APIM; + +/// +/// Test cases for APIM policy files with complex syntax. +/// +public sealed class APIMPolicyTests : BaseTests +{ + /// + /// Test case for https://github.com/Azure/PSRule.Rules.Azure/issues/3184 + /// + [Fact] + public void APIMPolicyReader_WhenExpressionIsFound_ShouldEscapeAndLoad() + { + using var reader = ReadContentFromFile("APIM/Tests.Policy.1.xml"); + + var doc = new XmlDocument(); + doc.Load(reader); + + var node = doc.SelectSingleNode("//set-variable"); + + Assert.Equal("@(context.Request.Headers.GetValueOrDefault(\"X-Original-Host\", \"NotAvailable\"))", node.Attributes["value"].Value); + } + + #region Helper methods + + private static XmlReader ReadContentFromFile(string fileName) + { + var content = GetContent(fileName); + return APIMPolicyReader.ReadContent(content); + } + + #endregion Helper methods +} diff --git a/tests/PSRule.Rules.Azure.Tests/APIM/Tests.Policy.1.xml b/tests/PSRule.Rules.Azure.Tests/APIM/Tests.Policy.1.xml new file mode 100644 index 00000000000..e85abc72011 --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/APIM/Tests.Policy.1.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/tests/PSRule.Rules.Azure.Tests/BaseTests.cs b/tests/PSRule.Rules.Azure.Tests/BaseTests.cs new file mode 100644 index 00000000000..a1759c2d28e --- /dev/null +++ b/tests/PSRule.Rules.Azure.Tests/BaseTests.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; + +namespace PSRule.Rules.Azure; + +public abstract class BaseTests +{ + protected static string GetSourcePath(string fileName) + { + return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileName); + } + + protected static string GetContent(string fileName) + { + var path = GetSourcePath(fileName); + return File.ReadAllText(path); + } +} 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 e12618e87bd..59bb55e6a5d 100644 --- a/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj +++ b/tests/PSRule.Rules.Azure.Tests/PSRule.Rules.Azure.Tests.csproj @@ -317,6 +317,9 @@ PreserveNewest + + PreserveNewest + diff --git a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTestsBase.cs b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTestsBase.cs index f6e755ee561..4bf848f9cab 100644 --- a/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTestsBase.cs +++ b/tests/PSRule.Rules.Azure.Tests/TemplateVisitorTestsBase.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; -using System.IO; using System.Linq; using Newtonsoft.Json.Linq; using PSRule.Rules.Azure.Configuration; @@ -12,15 +10,10 @@ namespace PSRule.Rules.Azure; -public abstract class TemplateVisitorTestsBase +public abstract class TemplateVisitorTestsBase : BaseTests { #region Helper methods - protected static string GetSourcePath(string fileName) - { - return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileName); - } - protected static JObject[] ProcessTemplate(string templateFile, string parametersFile) { var context = new PipelineContext(PSRuleOption.Default, null);