diff --git a/src/services/transitive-resolver.ts b/src/services/transitive-resolver.ts index 1251e82..5dab42f 100644 --- a/src/services/transitive-resolver.ts +++ b/src/services/transitive-resolver.ts @@ -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 = /|$)||$)|<\/set-body\s*>|/g; +const ATTRIBUTE_PATTERN = /\s+([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; const BACKEND_PATTERN = /([ * Scan policy XML content for references to other resources. * * Detects: - * - Named values: {{namedValueName}} syntax + * - Named values: {{namedValueName}} syntax outside Liquid template bodies * - Backends: * - Policy fragments: */ 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('')) { + continue; + } + if (tag.startsWith(' 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, diff --git a/tests/unit/services/extract-service.test.ts b/tests/unit/services/extract-service.test.ts index 090b820..191230c 100644 --- a/tests/unit/services/extract-service.test.ts +++ b/tests/unit/services/extract-service.test.ts @@ -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 = ` + {{real-value}} + {{context.Request.MatchedParameters["id"]}} + + {{body.envelope.body.Test_Result.test}} + `; + 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'; diff --git a/tests/unit/services/transitive-resolver.test.ts b/tests/unit/services/transitive-resolver.test.ts index 8d05947..17837e9 100644 --- a/tests/unit/services/transitive-resolver.test.ts +++ b/tests/unit/services/transitive-resolver.test.ts @@ -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 = ` + + + {{my-secret}} + + {{context.Request.MatchedParameters["id"]}} + + + + + + {"result": "{{body.envelope.body.Test_Result.test}}"} + + + + + {"code": "{{body.envelope.body.fault.faultcode}}", + "message": "{{body.envelope.body.fault.faultstring}}"} + + + + + `; + + 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 = `{{real-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 = `{{plain-value}} + {{body.value}} + @{ return "{{expression-value}}"; }`; + + expect(scanPolicyReferences(policy)).toEqual([ + { type: ResourceType.NamedValue, name: 'plain-value' }, + { type: ResourceType.NamedValue, name: 'parse-date' }, + { type: ResourceType.NamedValue, name: 'expression-value' }, + ]); + }); + + it.each([ + '{{body.value}}]]>', + '{{body.value}}', + '{{body.first}}{{body.second}}', + '{{body.first}}{{body.second}}', + ])('ignores the entire Liquid body despite embedded boundaries: %s', (body) => { + const policy = `{{before}} + ${body} + {{after}}`; + + expect(scanPolicyReferences(policy)).toEqual([ + { type: ResourceType.NamedValue, name: 'before' }, + { type: ResourceType.NamedValue, name: 'parse-date' }, + { type: ResourceType.NamedValue, name: 'after' }, + ]); + }); + + it.each([ + '', + '{{comment-value}}]]>', + ])('does not treat tag-like text as a Liquid element: %s', (body) => { + const policy = `${body}{{plain-value}} + {{body.value}}`; + + 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 = `@{ return 1 < 2 && true ? "{{expression-value}}" : ""; } + + {{after-empty}} + + + + {{body.value}} + `; + + 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 = ''; const refs = scanPolicyReferences(policy);