Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/services/transitive-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { getResourceDescriptorKey } from '../lib/resource-path.js';
* Reference detection patterns for policy XML content.
*/
const NAMED_VALUE_PATTERN = /\{\{([^}]+)\}\}/g;
const POLICY_BOUNDARY_PATTERN = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<\/set-body\s*>|<set-body(?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*\s*\/?>/g;
const ATTRIBUTE_PATTERN = /\s+([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
const BACKEND_PATTERN = /<set-backend-service\s+backend-id="([^"]+)"/g;
const FRAGMENT_PATTERN = /<include-fragment\s+fragment-id="([^"]+)"/g;

Expand All @@ -41,15 +43,44 @@ const POLICY_RESOURCE_TYPES = new Set<ResourceType>([
* Scan policy XML content for references to other resources.
*
* Detects:
* - Named values: {{namedValueName}} syntax
* - Named values: {{namedValueName}} syntax outside Liquid template bodies
* - Backends: <set-backend-service backend-id="backendName">
* - Policy fragments: <include-fragment fragment-id="fragmentName">
*/
export function scanPolicyReferences(policyXml: string): TransitiveDependency[] {
const dependencies: TransitiveDependency[] = [];
const namedValueParts: string[] = [];
let retainedStart = 0;
let liquidDepth = 0;

for (const boundary of policyXml.matchAll(POLICY_BOUNDARY_PATTERN)) {
const tag = boundary[0];
if (tag.startsWith('<!') || tag.endsWith('/>')) {
continue;
}
if (tag.startsWith('</')) {
if (liquidDepth > 0 && --liquidDepth === 0) {
retainedStart = boundary.index;
}
} else if (liquidDepth > 0) {
liquidDepth++;
} else {
for (const attribute of tag.matchAll(ATTRIBUTE_PATTERN)) {
if (attribute[1] === 'template' && (attribute[2] ?? attribute[3]) === 'liquid') {
const openingEnd = boundary.index + tag.length;
namedValueParts.push(policyXml.slice(retainedStart, openingEnd));
retainedStart = openingEnd;
liquidDepth = 1;
break;
}
}
}
}
namedValueParts.push(policyXml.slice(retainedStart));
const namedValueXml = namedValueParts.join('');

// Named value references
for (const match of policyXml.matchAll(NAMED_VALUE_PATTERN)) {
for (const match of namedValueXml.matchAll(NAMED_VALUE_PATTERN)) {
if (match[1]) {
dependencies.push({
type: ResourceType.NamedValue,
Expand Down
55 changes: 55 additions & 0 deletions tests/unit/services/extract-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,61 @@ describe('extract-service', () => {
expect(result.exitCode).toBe(0);
});

it.each([ResourceType.ServicePolicy, ResourceType.PolicyFragment])(
'extracts %s with Liquid without false lookups or policy changes',
async (policyType) => {
const policy = `<policies><inbound>
<set-header name="Auth"><value>{{real-value}}</value></set-header>
<set-body template="liquid">{{context.Request.MatchedParameters["id"]}}</set-body>
</inbound><outbound>
<set-body template="liquid">{{body.envelope.body.Test_Result.test}}</set-body>
</outbound></policies>`;
const policyResource = { name: 'liquid-policy', properties: { value: policy } };
const client = createMockClient(
policyType === ResourceType.PolicyFragment
? { [ResourceType.PolicyFragment]: [policyResource] }
: {}
);
client.getResource.mockImplementation(async (_context, descriptor: ResourceDescriptor) => {
if (policyType === ResourceType.ServicePolicy && descriptor.type === policyType) {
return policyResource;
}
if (descriptor.type === ResourceType.NamedValue && descriptor.nameParts[0] === 'real-value') {
return { name: 'real-value', properties: { value: 'configured-value' } };
}
return undefined;
});
const store = createMockStore();

const result = await runExtraction(client, store, {
service: testContext,
outputDir: '/output',
includeTransitive: true,
filter: { apis: [], namedValues: [] },
logLevel: LogLevel.INFO,
});

expect(result.exitCode).toBe(0);
expect(result.totalErrors).toBe(0);
const namedValueRequests = client.getResource.mock.calls
.map(([, descriptor]) => descriptor as ResourceDescriptor)
.filter((descriptor) => descriptor.type === ResourceType.NamedValue);
expect(namedValueRequests).toEqual([
expect.objectContaining({ nameParts: ['real-value'] }),
]);
if (policyType === ResourceType.ServicePolicy) {
expect(store.writeContent).toHaveBeenCalledWith(
'/output', expect.objectContaining({ type: policyType }), policy, 'policy'
);
expect(result.collectedPolicies.get('service-policy')).toBe(policy);
} else {
expect(store.writeResource).toHaveBeenCalledWith(
'/output', expect.objectContaining({ type: policyType }), policyResource
);
}
}
);

it('should extract backend pool members and policy fragment dependencies transitively', async () => {
const backendId =
'/subscriptions/s/resourceGroups/r/providers/Microsoft.ApiManagement/service/a/backends/member';
Expand Down
107 changes: 107 additions & 0 deletions tests/unit/services/transitive-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,113 @@ describe('transitive-resolver', () => {
expect(nvRefs[1]?.name).toBe('secret-2');
});

it('ignores Liquid output expressions while retaining real policy dependencies', () => {
const policy = `
<policies>
<inbound>
<set-header name="Auth"><value>{{my-secret}}</value></set-header>
<set-body template="liquid">
<request>{{context.Request.MatchedParameters["id"]}}</request>
</set-body>
<set-backend-service backend-id="soap-backend" />
</inbound>
<outbound>
<set-body template="liquid">
{"result": "{{body.envelope.body.Test_Result.test}}"}
</set-body>
</outbound>
<on-error>
<set-body template="liquid">
{"code": "{{body.envelope.body.fault.faultcode}}",
"message": "{{body.envelope.body.fault.faultstring}}"}
</set-body>
<include-fragment fragment-id="error-handler" />
</on-error>
</policies>
`;

expect(scanPolicyReferences(policy)).toEqual([
{ type: ResourceType.NamedValue, name: 'my-secret' },
{ type: ResourceType.Backend, name: 'soap-backend' },
{ type: ResourceType.PolicyFragment, name: 'error-handler' },
]);
});

it.each([
'template="liquid"',
"template='liquid'",
'parse-date="false"\n template = "liquid" xsi-nil="blank"',
])('ignores Liquid variables and filters with attributes %s', (attributes) => {
const policy = `<set-body ${attributes}><![CDATA[
{% assign result = body.value %}{{result}} {{ body.value | Escape }}
]]></set-body><value>{{real-value}}</value>`;

expect(scanPolicyReferences(policy)).toEqual([
{ type: ResourceType.NamedValue, name: 'real-value' },
]);
});

it('retains named values in non-Liquid bodies and Liquid opening attributes', () => {
const policy = `<set-body>{{plain-value}}</set-body>
<set-body template="liquid" parse-date="{{parse-date}}">{{body.value}}</set-body>
<set-body>@{ return "{{expression-value}}"; }</set-body>`;

expect(scanPolicyReferences(policy)).toEqual([
{ type: ResourceType.NamedValue, name: 'plain-value' },
{ type: ResourceType.NamedValue, name: 'parse-date' },
{ type: ResourceType.NamedValue, name: 'expression-value' },
]);
});

it.each([
'<![CDATA[</set-body>{{body.value}}]]>',
'<!-- </set-body> -->{{body.value}}',
'<root><set-body>{{body.first}}</set-body><value>{{body.second}}</value></root>',
'<set-body /><set-body template="liquid">{{body.first}}</set-body>{{body.second}}',
])('ignores the entire Liquid body despite embedded boundaries: %s', (body) => {
const policy = `<value>{{before}}</value>
<set-body template="liquid" parse-date="{{parse-date}}">${body}</set-body>
<set-body>{{after}}</set-body>`;

expect(scanPolicyReferences(policy)).toEqual([
{ type: ResourceType.NamedValue, name: 'before' },
{ type: ResourceType.NamedValue, name: 'parse-date' },
{ type: ResourceType.NamedValue, name: 'after' },
]);
});

it.each([
'<!-- <set-body template="liquid">{{comment-value}}</set-body> -->',
'<![CDATA[<set-body template="liquid">{{comment-value}}</set-body>]]>',
])('does not treat tag-like text as a Liquid element: %s', (body) => {
const policy = `${body}<set-body>{{plain-value}}</set-body>
<set-body template="liquid">{{body.value}}</set-body>`;

expect(scanPolicyReferences(policy)).toEqual([
{ type: ResourceType.NamedValue, name: 'comment-value' },
{ type: ResourceType.NamedValue, name: 'plain-value' },
]);
});

it('tolerates raw C# expressions and empty Liquid elements without changing backend or fragment scanning', () => {
const policy = `<set-body>@{ return 1 < 2 && true ? "{{expression-value}}" : ""; }</set-body>
<set-body template="liquid" parse-date="{{parse-date}}" />
<value>{{after-empty}}</value>
<set-body template="liquid">
<set-backend-service backend-id="body-backend" />
<include-fragment fragment-id="body-fragment" />
{{body.value}}
</set-body>`;

expect(scanPolicyReferences(policy)).toEqual([
{ type: ResourceType.NamedValue, name: 'expression-value' },
{ type: ResourceType.NamedValue, name: 'parse-date' },
{ type: ResourceType.NamedValue, name: 'after-empty' },
{ type: ResourceType.Backend, name: 'body-backend' },
{ type: ResourceType.PolicyFragment, name: 'body-fragment' },
]);
});

it('should detect backend references', () => {
const policy = '<policies><inbound><set-backend-service backend-id="my-backend" /></inbound></policies>';
const refs = scanPolicyReferences(policy);
Expand Down