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);