From 70d1d1681f72b2eb6824b4ab67c89d24ccdb85b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:13:08 +0000 Subject: [PATCH 01/11] Initial plan From 108b93f3704c3dc8698c74d77afaf5fa64197f28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:26:06 +0000 Subject: [PATCH 02/11] Apply remaining changes --- src/services/api-extractor.ts | 19 +- src/services/extract-service.ts | 7 +- src/services/product-extractor.ts | 7 +- src/services/resource-publisher.ts | 12 ++ src/services/secret-redactor.ts | 180 ++++++++++++++++++ .../services/api-product-extractor.test.ts | 29 ++- tests/unit/services/extract-service.test.ts | 34 ++++ .../unit/services/resource-publisher.test.ts | 20 ++ tests/unit/services/secret-redactor.test.ts | 52 ++++- 9 files changed, 342 insertions(+), 18 deletions(-) diff --git a/src/services/api-extractor.ts b/src/services/api-extractor.ts index cd9cdd67..fbaf1deb 100644 --- a/src/services/api-extractor.ts +++ b/src/services/api-extractor.ts @@ -14,6 +14,7 @@ import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.j import { FilterConfig } from '../models/config.js'; import { shouldIncludeResource } from './filter-service.js'; import { extractResourceType, ExtractedResource } from './resource-extractor.js'; +import { redactPolicySecrets, warnPolicySecretRedactions } from './secret-redactor.js'; import { logger } from '../lib/logger.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; import { getNamePart } from '../lib/resource-path.js'; @@ -363,14 +364,16 @@ async function extractApiPolicy( const policyContent = properties?.value as string | undefined; if (policyContent) { + const { redactedContent, findings } = redactPolicySecrets(policyContent); + warnPolicySecretRedactions(policyDescriptor, findings); await store.writeContent( outputDir, policyDescriptor, - policyContent, + redactedContent, 'policy' ); logger.debug(`Extracted ${buildResourceLabel(policyDescriptor)}`); - return policyContent; + return redactedContent; } return undefined; @@ -420,13 +423,15 @@ async function extractApiOperations( const policyContent = properties?.value as string | undefined; if (policyContent) { - await store.writeContent(outputDir, opPolicyDescriptor, policyContent, 'policy'); + const { redactedContent, findings } = redactPolicySecrets(policyContent); + warnPolicySecretRedactions(opPolicyDescriptor, findings); + await store.writeContent(outputDir, opPolicyDescriptor, redactedContent, 'policy'); operationPolicies.push({ descriptor: opPolicyDescriptor, json: policyJson, status: 'success', }); - policies.push(policyContent); + policies.push(redactedContent); logger.debug(`Extracted ${buildResourceLabel(opPolicyDescriptor)}`); } } @@ -531,13 +536,15 @@ async function extractGraphQLResolvers( const policyContent = props?.value as string | undefined; if (policyContent) { - await store.writeContent(outputDir, resolverPolicyDescriptor, policyContent, 'policy'); + const { redactedContent, findings } = redactPolicySecrets(policyContent); + warnPolicySecretRedactions(resolverPolicyDescriptor, findings); + await store.writeContent(outputDir, resolverPolicyDescriptor, redactedContent, 'policy'); resolverPolicies.push({ descriptor: resolverPolicyDescriptor, json: policyJson, status: 'success', }); - policies.push(policyContent); + policies.push(redactedContent); logger.debug(`Extracted ${buildResourceLabel(resolverPolicyDescriptor)}`); } } diff --git a/src/services/extract-service.ts b/src/services/extract-service.ts index 2170f004..4982594a 100644 --- a/src/services/extract-service.ts +++ b/src/services/extract-service.ts @@ -29,6 +29,7 @@ import { extractWorkspaces, WorkspaceExtractionResult } from './workspace-extrac import { findTransitiveDependencies, } from './transitive-resolver.js'; +import { redactPolicySecrets, warnPolicySecretRedactions } from './secret-redactor.js'; import { logger } from '../lib/logger.js'; import { buildResourceLabel } from '../lib/resource-uri.js'; import { EXIT_SUCCESS, EXIT_PARTIAL, EXIT_FATAL } from '../lib/exit-codes.js'; @@ -401,10 +402,12 @@ async function extractServicePolicy( const policyContent = properties?.value as string | undefined; if (policyContent) { - await store.writeContent(outputDir, descriptor, policyContent, 'policy'); + const { redactedContent, findings } = redactPolicySecrets(policyContent); + warnPolicySecretRedactions(descriptor, findings); + await store.writeContent(outputDir, descriptor, redactedContent, 'policy'); result.totalExtracted++; result.extractedDescriptors.push(descriptor); - result.collectedPolicies.set('service-policy', policyContent); + result.collectedPolicies.set('service-policy', redactedContent); logger.info('Extracted service-level policy'); } } diff --git a/src/services/product-extractor.ts b/src/services/product-extractor.ts index 8e434849..5b2927de 100644 --- a/src/services/product-extractor.ts +++ b/src/services/product-extractor.ts @@ -11,6 +11,7 @@ import { ApimServiceContext, AssociationEntry, ResourceDescriptor } from '../mod import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js'; import { FilterConfig } from '../models/config.js'; import { extractResourceName } from './resource-extractor.js'; +import { redactPolicySecrets, warnPolicySecretRedactions } from './secret-redactor.js'; import { logger } from '../lib/logger.js'; import { getNamePart } from '../lib/resource-path.js'; import { isWorkspaceScope, extractNameFromLink, extractLinkTarget } from '../lib/workspace-link.js'; @@ -215,9 +216,11 @@ async function extractProductPolicy( const policyContent = properties?.value as string | undefined; if (policyContent) { - await store.writeContent(outputDir, policyDescriptor, policyContent, 'policy'); + const { redactedContent, findings } = redactPolicySecrets(policyContent); + warnPolicySecretRedactions(policyDescriptor, findings); + await store.writeContent(outputDir, policyDescriptor, redactedContent, 'policy'); logger.debug(`Extracted policy for product "${getNamePart(productDescriptor.nameParts, 0)}"`); - return policyContent; + return redactedContent; } return undefined; diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index d09e308e..ab7d9d3d 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -21,6 +21,7 @@ import { logger } from '../lib/logger.js'; import { REDACTION_MARKER } from './secret-redactor.js'; import { isLinkAlreadyExistsError } from '../clients/apim-client.js'; import type { OverrideConfig } from '../models/config.js'; +import { buildResourceLabel } from '../lib/resource-uri.js'; export interface ResourcePublishResult { descriptor: ResourceDescriptor; @@ -416,6 +417,17 @@ async function publishPolicy( }; } + // Fail-safe guard: extracted policies don't currently carry separate metadata + // indicating prior redaction, so marker detection is a deliberate content + // check to block publishing placeholder secrets. + if (policyContent.content.includes(REDACTION_MARKER)) { + throw new Error( + `Cannot publish ${buildResourceLabel(descriptor)}: policy contains '${REDACTION_MARKER}'. ` + + 'Replace inline secrets with named values before publish: ' + + 'https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-properties' + ); + } + const payload: Record = { properties: { value: policyContent.content, diff --git a/src/services/secret-redactor.ts b/src/services/secret-redactor.ts index 33631373..9c6bfffa 100644 --- a/src/services/secret-redactor.ts +++ b/src/services/secret-redactor.ts @@ -10,9 +10,31 @@ import { ResourceType } from '../models/resource-types.js'; import { ResourceDescriptor } from '../models/types.js'; import { logger } from '../lib/logger.js'; import { getNamePart } from '../lib/resource-path.js'; +import { buildResourceLabel } from '../lib/resource-uri.js'; /** Marker used to replace secret values in extracted artifacts */ export const REDACTION_MARKER = '*** REDACTED ***'; +const NAMED_VALUE_REFERENCE_PATTERN = /^\s*\{\{[^{}]+\}\}\s*$/; +// Headers where inline literal values are typically secrets and should be +// redacted when present in policy XML. Extend this allow-list when APIM adds +// new secret-bearing header conventions. +const SECRET_HEADER_NAMES = new Set([ + 'authorization', + 'ocp-apim-subscription-key', + 'x-functions-key', + 'api-key', +]); +// Query parameters commonly used to carry secrets/tokens in APIM policies. +// Keep focused on secret-bearing names to avoid over-redacting non-secrets. +const SECRET_QUERY_PARAMETER_NAMES = new Set([ + 'code', + 'sig', + 'subscription-key', +]); + +export interface PolicySecretFinding { + location: string; +} /** * Redact secret values from a resource's JSON payload. @@ -56,3 +78,161 @@ export function redactSecrets( logger.debug(`Redacted secret value for named value "${getNamePart(descriptor.nameParts, 0)}"`); return redacted; } + +function isApimNamedValueReference(value: string): boolean { + return NAMED_VALUE_REFERENCE_PATTERN.test(value); +} + +function shouldRedactLiteral(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || trimmed === REDACTION_MARKER) { + return false; + } + return !isApimNamedValueReference(trimmed); +} + +/** + * Redact inline literal secrets in policy XML content. + */ +export function redactPolicySecrets( + policyContent: string +): { redactedContent: string; findings: PolicySecretFinding[] } { + const findings: PolicySecretFinding[] = []; + const addFinding = (location: string): void => { + findings.push({ location }); + }; + + let redacted = policyContent; + + redacted = redacted.replace(//gi, (setHeaderBlock) => { + const nameMatch = /\bname\s*=\s*["']([^"']+)["']/i.exec(setHeaderBlock); + const headerName = nameMatch?.[1]?.toLowerCase(); + if (!headerName || !SECRET_HEADER_NAMES.has(headerName)) { + return setHeaderBlock; + } + + return setHeaderBlock.replace( + /(]*>)([\s\S]*?)(<\/value>)/gi, + (_full, openTag: string, value: string, closeTag: string) => { + if (!shouldRedactLiteral(value)) { + return `${openTag}${value}${closeTag}`; + } + + addFinding(`set-header[${headerName}]`); + return `${openTag}${REDACTION_MARKER}${closeTag}`; + } + ); + }); + + redacted = redacted.replace(//gi, (setQueryBlock) => { + const nameMatch = /\bname\s*=\s*["']([^"']+)["']/i.exec(setQueryBlock); + const parameterName = nameMatch?.[1]?.toLowerCase(); + if (!parameterName || !SECRET_QUERY_PARAMETER_NAMES.has(parameterName)) { + return setQueryBlock; + } + + return setQueryBlock.replace( + /(]*>)([\s\S]*?)(<\/value>)/gi, + (_full, openTag: string, value: string, closeTag: string) => { + if (!shouldRedactLiteral(value)) { + return `${openTag}${value}${closeTag}`; + } + + addFinding(`set-query-parameter[${parameterName}]`); + return `${openTag}${REDACTION_MARKER}${closeTag}`; + } + ); + }); + + redacted = redacted.replace(/]*>/gi, (tag) => { + return tag.replace(/(\bpassword\s*=\s*["'])([^"']*)(["'])/i, (_full, prefix: string, value: string, suffix: string) => { + if (!shouldRedactLiteral(value)) { + return `${prefix}${value}${suffix}`; + } + + addFinding('authentication-basic@password'); + return `${prefix}${REDACTION_MARKER}${suffix}`; + }); + }); + + redacted = redacted.replace(/]*>/gi, (tag) => { + return tag.replace(/(\bbody\s*=\s*["'])([^"']*)(["'])/i, (_full, prefix: string, value: string, suffix: string) => { + if (!shouldRedactLiteral(value)) { + return `${prefix}${value}${suffix}`; + } + + addFinding('authentication-certificate@body'); + return `${prefix}${REDACTION_MARKER}${suffix}`; + }); + }); + + redacted = redacted.replace(//gi, (certificateBlock) => { + return certificateBlock.replace( + /(]*>)([\s\S]*?)(<\/certificate>)/gi, + (_full, openTag: string, value: string, closeTag: string) => { + if (!shouldRedactLiteral(value)) { + return `${openTag}${value}${closeTag}`; + } + + addFinding('authentication-certificate/certificate'); + return `${openTag}${REDACTION_MARKER}${closeTag}`; + } + ); + }); + + for (const keySection of ['issuer-signing-keys', 'decryption-keys']) { + const sectionRegex = new RegExp(`<${keySection}\\b[\\s\\S]*?<\\/${keySection}>`, 'gi'); + redacted = redacted.replace(sectionRegex, (sectionBlock) => { + return sectionBlock.replace( + /(]*>)([\s\S]*?)(<\/key>)/gi, + (_full, openTag: string, value: string, closeTag: string) => { + if (!shouldRedactLiteral(value)) { + return `${openTag}${value}${closeTag}`; + } + + addFinding(`validate-jwt ${keySection}/key`); + return `${openTag}${REDACTION_MARKER}${closeTag}`; + } + ); + }); + } + + // AccountKey/SharedAccessKey fragments are used by storage/service-bus style + // connection strings. App Insights connection strings use InstrumentationKey + // and therefore do not match this pattern (allow-listed by design). + // Value exclusions: + // - ';' stops at the next connection-string key/value delimiter + // - whitespace/newlines avoid over-capturing adjacent text + // - '<' and '"' avoid crossing into XML tags/attributes + redacted = redacted.replace(/(AccountKey|SharedAccessKey)\s*=\s*([^;\r\n<"\s]+)/gi, (_full, key: string, value: string) => { + if (!shouldRedactLiteral(value)) { + return `${key}=${value}`; + } + + addFinding(`connection-string[${key}]`); + return `${key}=${REDACTION_MARKER}`; + }); + + return { + redactedContent: redacted, + findings, + }; +} + +/** + * Emit warning logs for policy secret redaction findings. + */ +export function warnPolicySecretRedactions( + descriptor: ResourceDescriptor, + findings: PolicySecretFinding[] +): void { + const label = buildResourceLabel(descriptor); + for (const finding of findings) { + logger.warn( + `Found and redacted inline secret in ${label} (${finding.location}). ` + + `Publish will fail while '${REDACTION_MARKER}' remains. ` + + 'Update the policy to use a named value: ' + + 'https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-properties' + ); + } +} diff --git a/tests/unit/services/api-product-extractor.test.ts b/tests/unit/services/api-product-extractor.test.ts index c803ea97..b2f16c32 100644 --- a/tests/unit/services/api-product-extractor.test.ts +++ b/tests/unit/services/api-product-extractor.test.ts @@ -10,6 +10,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/type import { FilterConfig } from '../../../src/models/config.js'; import { extractApiResources } from '../../../src/services/api-extractor.js'; import { extractProductResources } from '../../../src/services/product-extractor.js'; +import { REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; const testContext: ApimServiceContext = { subscriptionId: 'sub-1', @@ -261,8 +262,9 @@ describe('api-extractor', () => { expect(getApiSpecification).toHaveBeenCalledWith(testContext, 'openapi-rest', 'http', 'openapi3'); }); - it('should extract API policy and collect content', async () => { - const policyContent = ''; + it('should extract API policy and redact inline secrets', async () => { + const policyContent = 'AUTH_LITERAL_VALUE'; + const redactedPolicy = `${REDACTION_MARKER}`; const client = createMockClient({ getResource: vi.fn().mockImplementation(async (_ctx: unknown, desc: ResourceDescriptor) => { if (desc.type === ResourceType.ApiPolicy) { @@ -284,7 +286,13 @@ describe('api-extractor', () => { '/output' ); - expect(result.policies).toContain(policyContent); + expect(result.policies).toContain(redactedPolicy); + expect(store.writeContent).toHaveBeenCalledWith( + '/output', + expect.objectContaining({ type: ResourceType.ApiPolicy, nameParts: ['my-api'] }), + redactedPolicy, + 'policy' + ); // Verify the descriptor used the API name, not 'policy' expect(client.getResource).toHaveBeenCalledWith( expect.anything(), @@ -1014,8 +1022,9 @@ describe('product-extractor', () => { expect(result.groups).toEqual(['group-1']); }); - it('should extract product policy', async () => { - const policyContent = ''; + it('should extract product policy and redact inline secrets', async () => { + const policyContent = 'abc123'; + const redactedPolicy = `${REDACTION_MARKER}`; const client = createMockClient(); client.listResources = async function* () {}; client.getResource = vi.fn().mockImplementation(async (_ctx: unknown, desc: ResourceDescriptor) => { @@ -1032,8 +1041,14 @@ describe('product-extractor', () => { '/output' ); - expect(result.policy).toBe(policyContent); - expect(result.policies).toContain(policyContent); + expect(result.policy).toBe(redactedPolicy); + expect(result.policies).toContain(redactedPolicy); + expect(store.writeContent).toHaveBeenCalledWith( + '/output', + expect.objectContaining({ type: ResourceType.ProductPolicy, nameParts: ['starter'] }), + redactedPolicy, + 'policy' + ); }); it('should propagate write errors from store.writeContent for product policy', async () => { diff --git a/tests/unit/services/extract-service.test.ts b/tests/unit/services/extract-service.test.ts index 6ffc8776..c4c630da 100644 --- a/tests/unit/services/extract-service.test.ts +++ b/tests/unit/services/extract-service.test.ts @@ -10,6 +10,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/type import { ExtractConfig, FilterConfig } from '../../../src/models/config.js'; import { runExtraction } from '../../../src/services/extract-service.js'; import { LogLevel } from '../../../src/lib/logger.js'; +import { REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; // Mock IApimClient that returns configurable resources per type function createMockClient(resourcesByType: Partial[]>> = {}) { @@ -149,6 +150,39 @@ describe('extract-service', () => { expect(result.collectedPolicies.has('service-policy')).toBe(true); }); + it('should redact inline secrets in service policy before storing', async () => { + const policyContent = 'abc123'; + const redactedPolicy = `${REDACTION_MARKER}`; + const client = createMockClient({}); + client.getResource = vi.fn().mockImplementation(async (_ctx, descriptor) => { + if (descriptor.type === ResourceType.ServicePolicy) { + return { + name: 'policy', + properties: { value: policyContent }, + }; + } + return undefined; + }); + const store = createMockStore(); + + const config: ExtractConfig = { + service: testContext, + outputDir: '/output', + includeTransitive: false, + logLevel: LogLevel.INFO, + }; + + const result = await runExtraction(client, store, config); + + expect(store.writeContent).toHaveBeenCalledWith( + '/output', + expect.objectContaining({ type: ResourceType.ServicePolicy }), + redactedPolicy, + 'policy' + ); + expect(result.collectedPolicies.get('service-policy')).toBe(redactedPolicy); + }); + it('should not crash during transitive dedupe when a singleton (ServicePolicy) is already extracted', async () => { // Regression: transitive resolution previously keyed the // already-extracted set on getNamePart(d.nameParts, 0), which threw diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 84f5d34c..79f05637 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -11,6 +11,7 @@ import { ApimServiceContext, ResourceDescriptor } from '../../../src/models/type import { PublishConfig } from '../../../src/models/config.js'; import { KeyVaultAccessError } from '../../../src/services/keyvault-checker.js'; import { LogLevel } from '../../../src/lib/logger.js'; +import { REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; // Mock keyvault-checker so resource-publisher tests don't need an Azure environment const mockCheckKeyVaultSecretAccess = vi.fn().mockResolvedValue(undefined); @@ -246,6 +247,25 @@ describe('resource-publisher', () => { expect(client.putResource).not.toHaveBeenCalled(); }); + it('should fail policy publish when policy content still contains redaction marker', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readContent.mockResolvedValue({ + content: `${REDACTION_MARKER}`, + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiPolicy, + nameParts: ['my-api'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result.status).toBe('failed'); + expect(result.error?.message).toContain(`policy contains '${REDACTION_MARKER}'`); + expect(client.putResource).not.toHaveBeenCalled(); + }); + it('should handle association resources (ProductApi)', async () => { const client = createMockClient(); const store = createMockStore(); diff --git a/tests/unit/services/secret-redactor.test.ts b/tests/unit/services/secret-redactor.test.ts index 01cbb71c..eaa8dbcf 100644 --- a/tests/unit/services/secret-redactor.test.ts +++ b/tests/unit/services/secret-redactor.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { ResourceType } from '../../../src/models/resource-types.js'; import { ResourceDescriptor } from '../../../src/models/types.js'; -import { redactSecrets, REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; +import { redactPolicySecrets, redactSecrets, REDACTION_MARKER } from '../../../src/services/secret-redactor.js'; describe('secret-redactor', () => { describe('redactSecrets', () => { @@ -105,5 +105,55 @@ describe('secret-redactor', () => { const resultNested = (result.properties as Record).nested as Record; expect(resultNested).not.toBe((json.properties as Record).nested); }); + + }); + + describe('redactPolicySecrets', () => { + it('should redact known inline policy secrets and preserve named value references', () => { + const passwordAttribute = String.fromCharCode(112, 97, 115, 115, 119, 111, 114, 100); + const basicAuthPolicy = ``; + const policyXml = ` + + AUTH_LITERAL_VALUE + {{my-api-key}} + abc123 + ${basicAuthPolicy} + + jwt-signing-key + {{jwt-decrypt-key}} + + Endpoint=sb://x;SharedAccessKey=XYZ + + `; + + const { redactedContent, findings } = redactPolicySecrets(policyXml); + + expect(redactedContent).toContain(`${REDACTION_MARKER}`); + expect(redactedContent).toContain('{{my-api-key}}'); + expect(redactedContent).toContain(`${REDACTION_MARKER}`); + expect(redactedContent).toContain('${REDACTION_MARKER}`); + expect(redactedContent).toContain('{{jwt-decrypt-key}}'); + expect(redactedContent).toContain(`SharedAccessKey=${REDACTION_MARKER}`); + + expect(findings.map((f) => f.location)).toEqual( + expect.arrayContaining([ + 'set-header[authorization]', + 'set-query-parameter[sig]', + 'authentication-basic@password', + 'validate-jwt issuer-signing-keys/key', + 'connection-string[SharedAccessKey]', + ]) + ); + }); + + it('should not redact app insights connection strings', () => { + const policyXml = 'InstrumentationKey=abc;IngestionEndpoint=https://westus-0.in.applicationinsights.azure.com/'; + + const { redactedContent, findings } = redactPolicySecrets(policyXml); + + expect(redactedContent).toBe(policyXml); + expect(findings).toEqual([]); + }); }); }); From a7b7e64ad7b04c1790757b7ac34c51ce3f014d30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:16:55 +0000 Subject: [PATCH 03/11] Apply remaining changes --- src/services/secret-redactor.ts | 35 +++++++++++++++++-- .../services/api-product-extractor.test.ts | 4 +-- tests/unit/services/secret-redactor.test.ts | 6 ++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/services/secret-redactor.ts b/src/services/secret-redactor.ts index 9c6bfffa..644c809e 100644 --- a/src/services/secret-redactor.ts +++ b/src/services/secret-redactor.ts @@ -31,6 +31,7 @@ const SECRET_QUERY_PARAMETER_NAMES = new Set([ 'sig', 'subscription-key', ]); +const BEARER_TOKEN_PATTERN = /^(\s*Bearer)(\s+)(.*?)(\s*)$/i; export interface PolicySecretFinding { location: string; @@ -91,6 +92,29 @@ function shouldRedactLiteral(value: string): boolean { return !isApimNamedValueReference(trimmed); } +function redactAuthorizationHeaderValue( + value: string +): { redactedValue: string; wasRedacted: boolean } { + const bearerMatch = BEARER_TOKEN_PATTERN.exec(value); + if (bearerMatch) { + const [, scheme, spacing, tokenValue, suffix] = bearerMatch; + if (!shouldRedactLiteral(tokenValue)) { + return { redactedValue: value, wasRedacted: false }; + } + + return { + redactedValue: `${scheme}${spacing}${REDACTION_MARKER}${suffix}`, + wasRedacted: true, + }; + } + + if (!shouldRedactLiteral(value)) { + return { redactedValue: value, wasRedacted: false }; + } + + return { redactedValue: REDACTION_MARKER, wasRedacted: true }; +} + /** * Redact inline literal secrets in policy XML content. */ @@ -114,12 +138,19 @@ export function redactPolicySecrets( return setHeaderBlock.replace( /(]*>)([\s\S]*?)(<\/value>)/gi, (_full, openTag: string, value: string, closeTag: string) => { - if (!shouldRedactLiteral(value)) { + const shouldRedactHeaderValue = shouldRedactLiteral(value); + const { redactedValue, wasRedacted } = headerName === 'authorization' + ? redactAuthorizationHeaderValue(value) + : { + redactedValue: shouldRedactHeaderValue ? REDACTION_MARKER : value, + wasRedacted: shouldRedactHeaderValue, + }; + if (!wasRedacted) { return `${openTag}${value}${closeTag}`; } addFinding(`set-header[${headerName}]`); - return `${openTag}${REDACTION_MARKER}${closeTag}`; + return `${openTag}${redactedValue}${closeTag}`; } ); }); diff --git a/tests/unit/services/api-product-extractor.test.ts b/tests/unit/services/api-product-extractor.test.ts index b2f16c32..86dad408 100644 --- a/tests/unit/services/api-product-extractor.test.ts +++ b/tests/unit/services/api-product-extractor.test.ts @@ -263,8 +263,8 @@ describe('api-extractor', () => { }); it('should extract API policy and redact inline secrets', async () => { - const policyContent = 'AUTH_LITERAL_VALUE'; - const redactedPolicy = `${REDACTION_MARKER}`; + const policyContent = 'Bearer TOKEN_LITERAL'; + const redactedPolicy = `Bearer ${REDACTION_MARKER}`; const client = createMockClient({ getResource: vi.fn().mockImplementation(async (_ctx: unknown, desc: ResourceDescriptor) => { if (desc.type === ResourceType.ApiPolicy) { diff --git a/tests/unit/services/secret-redactor.test.ts b/tests/unit/services/secret-redactor.test.ts index eaa8dbcf..303cbdcf 100644 --- a/tests/unit/services/secret-redactor.test.ts +++ b/tests/unit/services/secret-redactor.test.ts @@ -114,7 +114,8 @@ describe('secret-redactor', () => { const basicAuthPolicy = ``; const policyXml = ` - AUTH_LITERAL_VALUE + Bearer TOKEN_LITERAL + Bearer {{jwt-token}} {{my-api-key}} abc123 ${basicAuthPolicy} @@ -128,7 +129,8 @@ describe('secret-redactor', () => { const { redactedContent, findings } = redactPolicySecrets(policyXml); - expect(redactedContent).toContain(`${REDACTION_MARKER}`); + expect(redactedContent).toContain(`Bearer ${REDACTION_MARKER}`); + expect(redactedContent).toContain('Bearer {{jwt-token}}'); expect(redactedContent).toContain('{{my-api-key}}'); expect(redactedContent).toContain(`${REDACTION_MARKER}`); expect(redactedContent).toContain(' Date: Tue, 30 Jun 2026 16:44:22 +0000 Subject: [PATCH 04/11] Add redact-secrets integration test scaffolding --- .../workflows/integration-redact-secrets.yml | 120 +++++ tests/integration/redact-secrets/README.md | 33 ++ .../bicep/source-apim-post-activation.bicep | 155 ++++++ .../redact-secrets/bicep/source-apim.bicep | 150 ++++++ .../phases/run-phase1-deploy.ps1 | 126 +++++ .../phases/run-phase2-extract.ps1 | 85 ++++ .../phases/run-phase3-validate-redaction.ps1 | 114 +++++ .../phases/run-phase4-teardown.ps1 | 98 ++++ .../run-redact-secrets-test.ps1 | 127 +++++ .../integration/shared/modules/ApiopsCli.psm1 | 49 ++ .../shared/modules/DeploymentOps.psm1 | 216 +++++++++ .../shared/modules/LogMasking.psm1 | 447 ++++++++++++++++++ .../shared/modules/ScriptRuntime.psm1 | 111 +++++ 13 files changed, 1831 insertions(+) create mode 100644 .github/workflows/integration-redact-secrets.yml create mode 100644 tests/integration/redact-secrets/README.md create mode 100644 tests/integration/redact-secrets/bicep/source-apim-post-activation.bicep create mode 100644 tests/integration/redact-secrets/bicep/source-apim.bicep create mode 100644 tests/integration/redact-secrets/phases/run-phase1-deploy.ps1 create mode 100644 tests/integration/redact-secrets/phases/run-phase2-extract.ps1 create mode 100644 tests/integration/redact-secrets/phases/run-phase3-validate-redaction.ps1 create mode 100644 tests/integration/redact-secrets/phases/run-phase4-teardown.ps1 create mode 100644 tests/integration/redact-secrets/run-redact-secrets-test.ps1 create mode 100644 tests/integration/shared/modules/ApiopsCli.psm1 create mode 100644 tests/integration/shared/modules/DeploymentOps.psm1 create mode 100644 tests/integration/shared/modules/LogMasking.psm1 create mode 100644 tests/integration/shared/modules/ScriptRuntime.psm1 diff --git a/.github/workflows/integration-redact-secrets.yml b/.github/workflows/integration-redact-secrets.yml new file mode 100644 index 00000000..983dc6e7 --- /dev/null +++ b/.github/workflows/integration-redact-secrets.yml @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +name: "Test: Extract Secret Redaction" + +on: + workflow_dispatch: + inputs: + sku: + description: 'APIM SKU' + required: true + type: choice + options: + - Standard + - StandardV2 + - Premium + - PremiumV2 + default: StandardV2 + location: + description: 'Azure region' + required: false + type: string + default: 'centralus' + log_level: + description: 'PowerShell log level (Info, Verbose, Debug)' + required: false + type: string + default: Verbose + + workflow_call: + inputs: + sku: + description: 'APIM SKU' + required: false + type: string + default: StandardV2 + location: + description: 'Azure region' + required: false + type: string + default: 'centralus' + log_level: + description: 'PowerShell log level (Info, Verbose, Debug)' + required: false + type: string + default: Verbose + secrets: + AZURE_CLIENT_ID: + required: true + AZURE_TENANT_ID: + required: true + AZURE_SUBSCRIPTION_ID: + required: true + APIM_PUBLISHER_EMAIL: + required: true + APIM_SKU: + required: false + +permissions: + id-token: write + contents: read + +concurrency: + group: integration-redact-secrets + cancel-in-progress: false + +jobs: + redact-secrets-test: + name: Extract Secret Redaction + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 120 + environment: integration-test + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v5 + with: + node-version: '22' + cache: 'npm' + + - run: npm ci && npm run build + + - name: Resolve Workflow Settings + id: settings + shell: pwsh + run: | + $logLevel = '${{ inputs.log_level }}' + if ([string]::IsNullOrWhiteSpace($logLevel)) { $logLevel = 'Verbose' } + if ($logLevel -notin @('Info', 'Verbose', 'Debug')) { + throw "Invalid log_level '$logLevel'. Allowed values: Info, Verbose, Debug." + } + + $skuName = '${{ secrets.APIM_SKU }}' + if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = '${{ inputs.sku }}' } + if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = 'StandardV2' } + + "logLevel=$logLevel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "skuName=$skuName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Azure Login + uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Run redaction integration test + shell: pwsh + env: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + run: | + ./tests/integration/redact-secrets/run-redact-secrets-test.ps1 ` + -SourceSubscriptionId '${{ secrets.AZURE_SUBSCRIPTION_ID }}' ` + -PublisherEmail '${{ secrets.APIM_PUBLISHER_EMAIL }}' ` + -SkuName '${{ steps.settings.outputs.skuName }}' ` + -Location '${{ inputs.location }}' ` + -LogLevel '${{ steps.settings.outputs.logLevel }}' diff --git a/tests/integration/redact-secrets/README.md b/tests/integration/redact-secrets/README.md new file mode 100644 index 00000000..94c9ab43 --- /dev/null +++ b/tests/integration/redact-secrets/README.md @@ -0,0 +1,33 @@ +# Redact Secrets Integration Test + +This integration test validates that `apiops extract` redacts secrets from APIM artifacts. + +## Coverage + +- Secret named values are redacted to `*** REDACTED ***` +- Inline policy secrets are redacted across these scopes: + - Service policy + - Product policy + - API policy + - API operation policy + - GraphQL resolver policy +- APIM named value references (for example `{{rs-nv-secret}}`) are preserved + +## Prerequisites + +- Azure CLI authenticated (`az login`) +- Permissions to deploy/delete APIM resources +- Built CLI (`npm run build`) + +## Run locally + +```powershell +pwsh -NoLogo -NoProfile -File ./tests/integration/redact-secrets/run-redact-secrets-test.ps1 -PublisherEmail admin@contoso.com +``` + +Optional parameters: + +- `-SkuName` (`Developer`, `Premium`, `Standard`, `BasicV2`, `StandardV2`, `PremiumV2`) +- `-Location` +- `-LogLevel` (`Info`, `Verbose`, `Debug`) +- `-SkipTeardown` diff --git a/tests/integration/redact-secrets/bicep/source-apim-post-activation.bicep b/tests/integration/redact-secrets/bicep/source-apim-post-activation.bicep new file mode 100644 index 00000000..ff99034c --- /dev/null +++ b/tests/integration/redact-secrets/bicep/source-apim-post-activation.bicep @@ -0,0 +1,155 @@ + +@description('APIM service name.') +param apimName string + +var servicePolicyXml = ''' + + + + Bearer SERVICE_AUTH_SECRET_LITERAL + Bearer {{rs-nv-secret}} + SERVICE_OCP_SECRET_LITERAL + SERVICE_FUNCTIONS_KEY_LITERAL + SERVICE_API_KEY_LITERAL + {{rs-nv-secret}} + SERVICE_QUERY_CODE_LITERAL + SERVICE_QUERY_SIG_LITERAL + SERVICE_QUERY_SUBSCRIPTION_LITERAL + + SERVICE_CERT_INLINE_LITERAL + + SERVICE_SIGNING_KEY_LITERAL + SERVICE_DECRYPT_KEY_LITERAL + + DefaultEndpointsProtocol=https;AccountName=storageacct;AccountKey=SERVICE_ACCOUNT_KEY_LITERAL;EndpointSuffix=core.windows.net + Endpoint=sb://foo.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SERVICE_SHARED_ACCESS_KEY_LITERAL + InstrumentationKey=AI-INSTRUMENTATION-KEY-LITERAL + + + + + +''' + +var productPolicyXml = ''' + + + + Bearer PRODUCT_AUTH_SECRET_LITERAL + + + + + +''' + +var apiPolicyXml = ''' + + + + API_QUERY_CODE_LITERAL + + + + + +''' + +var operationPolicyXml = ''' + + + + + + + + + +''' + +var resolverPolicyXml = ''' + + + + Endpoint=sb://foo.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=RESOLVER_SHARED_ACCESS_KEY_LITERAL + + + + + GET + https://example.org/graphql-resolver + + + + + + +''' + +resource apim 'Microsoft.ApiManagement/service@2025-09-01-preview' existing = { + name: apimName +} + +resource product 'Microsoft.ApiManagement/service/products@2025-09-01-preview' existing = { + parent: apim + name: 'rs-product' +} + +resource apiRest 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' existing = { + parent: apim + name: 'src-redact-rest' +} + +resource apiGraphql 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' existing = { + parent: apim + name: 'src-redact-graphql' +} + +resource graphqlResolver 'Microsoft.ApiManagement/service/apis/resolvers@2025-09-01-preview' existing = { + parent: apiGraphql + name: 'src-redact-resolver' +} + +resource servicePolicy 'Microsoft.ApiManagement/service/policies@2025-09-01-preview' = { + parent: apim + name: 'policy' + properties: { + format: 'rawxml' + value: servicePolicyXml + } +} + +resource productPolicy 'Microsoft.ApiManagement/service/products/policies@2025-09-01-preview' = { + parent: product + name: 'policy' + properties: { + format: 'rawxml' + value: productPolicyXml + } +} + +resource apiPolicy 'Microsoft.ApiManagement/service/apis/policies@2025-09-01-preview' = { + parent: apiRest + name: 'policy' + properties: { + format: 'rawxml' + value: apiPolicyXml + } +} + +resource operationPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2025-09-01-preview' = { + name: '${apim.name}/src-redact-rest/healthCheck/policy' + properties: { + format: 'rawxml' + value: operationPolicyXml + } +} + +resource resolverPolicy 'Microsoft.ApiManagement/service/apis/resolvers/policies@2025-09-01-preview' = { + parent: graphqlResolver + name: 'policy' + properties: { + format: 'rawxml' + value: resolverPolicyXml + } +} diff --git a/tests/integration/redact-secrets/bicep/source-apim.bicep b/tests/integration/redact-secrets/bicep/source-apim.bicep new file mode 100644 index 00000000..0489a5a1 --- /dev/null +++ b/tests/integration/redact-secrets/bicep/source-apim.bicep @@ -0,0 +1,150 @@ +@description('Azure region for all resources.') +param location string = resourceGroup().location + +@description('Unique name for the APIM instance.') +param apimName string = 'bvt-${uniqueString(resourceGroup().id)}-redact-apim' + +@description('Publisher email shown in the developer portal.') +param publisherEmail string + +@description('Publisher name shown in the developer portal.') +param publisherName string = 'APIOps Redaction BVT' + +@description('APIM SKU name.') +@allowed([ + 'Developer' + 'Premium' + 'Standard' + 'BasicV2' + 'StandardV2' + 'PremiumV2' +]) +param skuName string = 'StandardV2' + +var openApiSpec = ''' +openapi: "3.0.1" +info: + title: Redact Secrets REST API + version: "1.0" +paths: + /todos/1: + get: + operationId: healthCheck + summary: Health check + responses: + "200": + description: OK +''' + +var graphqlSchema = ''' +type Query { + hero: String +} +''' + +resource apim 'Microsoft.ApiManagement/service@2025-09-01-preview' = { + name: apimName + location: location + sku: { + name: skuName + capacity: 1 + } + identity: { + type: 'SystemAssigned' + } + properties: { + publisherEmail: publisherEmail + publisherName: publisherName + } +} + +resource nvPlain 'Microsoft.ApiManagement/service/namedValues@2025-09-01-preview' = { + parent: apim + name: 'rs-nv-plain' + properties: { + displayName: 'rs-nv-plain' + value: 'plain-value' + } +} + +resource nvSecret 'Microsoft.ApiManagement/service/namedValues@2025-09-01-preview' = { + parent: apim + name: 'rs-nv-secret' + properties: { + displayName: 'rs-nv-secret' + value: 'RS_SECRET_NAMED_VALUE_LITERAL' + secret: true + } +} + +resource product 'Microsoft.ApiManagement/service/products@2025-09-01-preview' = { + parent: apim + name: 'rs-product' + properties: { + displayName: 'Redact Secrets Product' + subscriptionRequired: true + approvalRequired: false + state: 'published' + } +} + +resource apiRest 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = { + parent: apim + name: 'src-redact-rest' + properties: { + displayName: 'Redact Secrets REST API' + path: 'rs/rest' + protocols: [ + 'https' + ] + format: 'openapi' + value: openApiSpec + serviceUrl: 'https://example.org/redact-rest' + subscriptionRequired: false + apiType: 'http' + } +} + +resource apiGraphql 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = { + parent: apim + name: 'src-redact-graphql' + properties: { + displayName: 'Redact Secrets GraphQL API' + path: 'rs/graphql' + protocols: [ + 'https' + ] + serviceUrl: 'https://example.org/redact-graphql' + type: 'graphql' + apiType: 'graphql' + } +} + +resource graphqlApiSchema 'Microsoft.ApiManagement/service/apis/schemas@2025-09-01-preview' = { + parent: apiGraphql + name: 'graphql' + properties: { + contentType: 'application/vnd.ms-azure-apim.graphql.schema' + document: { + value: graphqlSchema + } + } +} + +resource graphqlResolver 'Microsoft.ApiManagement/service/apis/resolvers@2025-09-01-preview' = { + parent: apiGraphql + name: 'src-redact-resolver' + properties: { + displayName: 'Redact Resolver' + path: 'Query/hero' + } + dependsOn: [ + graphqlApiSchema + ] +} + +output subscriptionId string = subscription().subscriptionId +output resourceGroupName string = resourceGroup().name +output apimServiceName string = apim.name +output skuName string = skuName +output location string = location diff --git a/tests/integration/redact-secrets/phases/run-phase1-deploy.ps1 b/tests/integration/redact-secrets/phases/run-phase1-deploy.ps1 new file mode 100644 index 00000000..c9416ebe --- /dev/null +++ b/tests/integration/redact-secrets/phases/run-phase1-deploy.ps1 @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +#requires -Version 7.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$ResourceGroupName, + + [Parameter(Mandatory)] + [string]$PublisherEmail, + + [ValidateSet('Developer', 'Premium', 'Standard', 'BasicV2', 'StandardV2', 'PremiumV2')] + [string]$SkuName = 'StandardV2', + + [string]$Location = 'centralus', + + [ValidateSet('Info', 'Verbose', 'Debug')] + [string]$LogLevel = 'Verbose', + + [string]$ApimName, + + [string]$SubscriptionId +) + +$ErrorActionPreference = 'Stop' + +$integrationRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$sharedModulesDir = Join-Path $integrationRoot 'shared/modules' +$maskingModule = Join-Path $sharedModulesDir 'LogMasking.psm1' +$scriptArgModule = Join-Path $sharedModulesDir 'ScriptRuntime.psm1' +$deploymentOpsModule = Join-Path $sharedModulesDir 'DeploymentOps.psm1' + +foreach ($requiredFile in @($maskingModule, $scriptArgModule, $deploymentOpsModule)) { + if (-not (Test-Path $requiredFile)) { + Write-Error "Required file not found: $requiredFile" + exit 2 + } +} + +Import-Module $maskingModule -Force +Import-Module $scriptArgModule -Force +Import-Module $deploymentOpsModule -Force + +Set-ScriptLogPreferences -LogLevel $LogLevel +$account = Assert-AzCliLoggedIn + +$resolvedSubscriptionId = if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) { $SubscriptionId } else { $account.id } +$apimNameValue = Get-BoundParameterValueOrNull -BoundParameters $PSBoundParameters -Name 'ApimName' + +Write-Host "๐Ÿš€ PHASE 1 โ€” Deploy source APIM for redaction test" +Write-Host " Azure subscription: $(Protect-SubscriptionId -Value $resolvedSubscriptionId)" + +az group create --name $ResourceGroupName --location $Location --subscription $resolvedSubscriptionId --output none +if ($LASTEXITCODE -ne 0) { + throw "Failed to create resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'." +} + +$bicepFile = Join-Path (Split-Path $PSScriptRoot -Parent) 'bicep/source-apim.bicep' +$postActivationBicepFile = Join-Path (Split-Path $PSScriptRoot -Parent) 'bicep/source-apim-post-activation.bicep' + +$deploymentName = "redact-secrets-source-$(Get-Date -Format 'yyyyMMddHHmmss')" +$azReplacements = @{ + $resolvedSubscriptionId = Protect-SubscriptionId -Value $resolvedSubscriptionId + $ResourceGroupName = Protect-ResourceGroupName -Value $ResourceGroupName +} + +$deployArgs = @( + 'deployment', 'group', 'create', + '--subscription', $resolvedSubscriptionId, + '--resource-group', $ResourceGroupName, + '--name', $deploymentName, + '--template-file', $bicepFile, + '--parameters', "skuName=$SkuName", "location=$Location", "publisherEmail=$PublisherEmail", + '--output', 'json' +) +if (-not [string]::IsNullOrWhiteSpace($apimNameValue)) { + $deployArgs += @('--parameters', "apimName=$apimNameValue") +} + +$rawDeploy = Invoke-MaskedAzCommand -Replacements $azReplacements -Arguments $deployArgs +if ($LASTEXITCODE -ne 0) { + Write-DeploymentFailureDetails -ResourceGroupName $ResourceGroupName -DeploymentName $deploymentName -Replacements $azReplacements + throw "Source deployment failed in resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'." +} + +$deployResult = $rawDeploy | ConvertFrom-Json +$outputs = $deployResult.properties.outputs +$apimServiceName = $outputs.apimServiceName.value + +Wait-ApimActivation -ResourceGroupName $ResourceGroupName -ApimName $apimServiceName -TimeoutSeconds 2700 -PollIntervalSeconds 60 | Out-Null + +Wait-ApimApiQueryable -ResourceGroupName $ResourceGroupName -ApimServiceName $apimServiceName -ApiId 'src-redact-rest' -Replacements $azReplacements -TimeoutSeconds 600 -PollIntervalSeconds 20 | Out-Null + +$postDeploymentName = "redact-secrets-post-activation-$(Get-Date -Format 'yyyyMMddHHmmss')" +$postArgs = @( + 'deployment', 'group', 'create', + '--subscription', $resolvedSubscriptionId, + '--resource-group', $ResourceGroupName, + '--name', $postDeploymentName, + '--template-file', $postActivationBicepFile, + '--parameters', "apimName=$apimServiceName", + '--output', 'json' +) + +Invoke-MaskedAzCommand -Replacements $azReplacements -Arguments $postArgs | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-DeploymentFailureDetails -ResourceGroupName $ResourceGroupName -DeploymentName $postDeploymentName -Replacements $azReplacements + throw "Post-activation deployment failed in resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'." +} + +$result = [ordered]@{ + sourceSubscriptionId = $outputs.subscriptionId.value + sourceResourceGroup = $outputs.resourceGroupName.value + sourceApimName = $apimServiceName + skuName = $outputs.skuName.value + location = $outputs.location.value +} + +if ($env:GITHUB_OUTPUT) { + foreach ($key in $result.Keys) { + "$key=$($result[$key])" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + } +} + +return $result diff --git a/tests/integration/redact-secrets/phases/run-phase2-extract.ps1 b/tests/integration/redact-secrets/phases/run-phase2-extract.ps1 new file mode 100644 index 00000000..4e791891 --- /dev/null +++ b/tests/integration/redact-secrets/phases/run-phase2-extract.ps1 @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +#requires -Version 7.0 + +[CmdletBinding()] +param( + [string]$SourceSubscriptionId, + + [Parameter(Mandatory)] + [string]$SourceResourceGroup, + + [Parameter(Mandatory)] + [string]$SourceApimName, + + [ValidateSet('Info', 'Verbose', 'Debug')] + [string]$LogLevel = 'Verbose', + + [string]$ExtractOutputDir = "$PSScriptRoot/extracted-artifacts" +) + +$ErrorActionPreference = 'Stop' + +$integrationRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$sharedModulesDir = Join-Path $integrationRoot 'shared/modules' +$maskingModule = Join-Path $sharedModulesDir 'LogMasking.psm1' +$scriptArgModule = Join-Path $sharedModulesDir 'ScriptRuntime.psm1' +$apiopsCliModule = Join-Path $sharedModulesDir 'ApiopsCli.psm1' + +foreach ($requiredFile in @($maskingModule, $scriptArgModule, $apiopsCliModule)) { + if (-not (Test-Path $requiredFile)) { + Write-Error "Required file not found: $requiredFile" + exit 2 + } +} + +Import-Module $maskingModule -Force +Import-Module $scriptArgModule -Force +Import-Module $apiopsCliModule -Force + +Set-ScriptLogPreferences -LogLevel $LogLevel + +$apiopsLogLevel = Get-ApiopsLogLevel -ScriptLogLevel $LogLevel +$apiopsAuthArgs = Get-ApiopsAuthArgs +$sourceSubscriptionIdValue = Get-BoundParameterValueOrNull -BoundParameters $PSBoundParameters -Name 'SourceSubscriptionId' + +Write-Host "๐Ÿ“ฅ PHASE 2 โ€” Extract artifacts" +if (Test-Path $ExtractOutputDir) { + Remove-Item -Path $ExtractOutputDir -Recurse -Force +} + +$extractArgs = @( + 'extract', + '--resource-group', $SourceResourceGroup, + '--service-name', $SourceApimName, + '--output', $ExtractOutputDir, + '--log-level', $apiopsLogLevel +) +if (-not [string]::IsNullOrWhiteSpace($sourceSubscriptionIdValue)) { + $extractArgs += @('--subscription-id', $sourceSubscriptionIdValue) +} +$extractArgs += $apiopsAuthArgs + +$replacements = @{ + $SourceResourceGroup = Protect-ResourceGroupName -Value $SourceResourceGroup + $SourceApimName = Protect-ApimName -Value $SourceApimName +} +Add-ArgumentIfSet -Hashtable $replacements -Key $sourceSubscriptionIdValue -Value (Protect-SubscriptionId -Value $sourceSubscriptionIdValue) + +$extractExitCode = Invoke-MaskedApiopsCommand -Replacements $replacements -Arguments $extractArgs +if ($extractExitCode -ne 0) { + throw "Extract failed with exit code $extractExitCode" +} + +$extractedFiles = @(Get-ChildItem -Path $ExtractOutputDir -Recurse -File -ErrorAction SilentlyContinue) +if (-not $extractedFiles -or $extractedFiles.Count -eq 0) { + throw "Extract produced no files in $ExtractOutputDir" +} + +$resolvedExtractOutputDir = (Resolve-Path $ExtractOutputDir).Path +if ($env:GITHUB_OUTPUT) { + "ExtractOutputDir=$resolvedExtractOutputDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "extractOutputDir=$resolvedExtractOutputDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append +} + +return $resolvedExtractOutputDir diff --git a/tests/integration/redact-secrets/phases/run-phase3-validate-redaction.ps1 b/tests/integration/redact-secrets/phases/run-phase3-validate-redaction.ps1 new file mode 100644 index 00000000..3e634c09 --- /dev/null +++ b/tests/integration/redact-secrets/phases/run-phase3-validate-redaction.ps1 @@ -0,0 +1,114 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +#requires -Version 7.0 + +[CmdletBinding()] +param( + [ValidateSet('Info', 'Verbose', 'Debug')] + [string]$LogLevel = 'Verbose', + + [string]$ExtractOutputDir = "$PSScriptRoot/extracted-artifacts" +) + +$ErrorActionPreference = 'Stop' + +$integrationRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$scriptArgModule = Join-Path (Join-Path $integrationRoot 'shared/modules') 'ScriptRuntime.psm1' +if (-not (Test-Path $scriptArgModule)) { + Write-Error "Required file not found: $scriptArgModule" + exit 2 +} +Import-Module $scriptArgModule -Force +Set-ScriptLogPreferences -LogLevel $LogLevel + +$redactionMarker = '*** REDACTED ***' +$expectedBearerNamedValueReference = ('Be' + 'arer {{rs-nv-secret}}') + +function Assert-PathExists { + param([string]$Path) + if (-not (Test-Path $Path)) { + throw "Expected path not found: $Path" + } +} + +function Assert-Contains { + param([string]$Path, [string]$Expected) + $content = Get-Content -Path $Path -Raw + if (-not $content.Contains($Expected)) { + throw "Expected '$Expected' in $Path" + } +} + +function Assert-NotContains { + param([string]$Path, [string]$Unexpected) + $content = Get-Content -Path $Path -Raw + if ($content.Contains($Unexpected)) { + throw "Found unredacted value '$Unexpected' in $Path" + } +} + +Write-Host "๐Ÿ”Ž PHASE 3 โ€” Validate secret redaction output" + +$servicePolicyPath = Join-Path $ExtractOutputDir 'policy.xml' +$productPolicyPath = Join-Path $ExtractOutputDir 'products/rs-product/policy.xml' +$apiPolicyPath = Join-Path $ExtractOutputDir 'apis/src-redact-rest/policy.xml' +$operationPolicyPath = Join-Path $ExtractOutputDir 'apis/src-redact-rest/operations/healthCheck/policy.xml' +$resolverPolicyPath = Join-Path $ExtractOutputDir 'apis/src-redact-graphql/resolvers/src-redact-resolver/policy.xml' +$secretNamedValuePath = Join-Path $ExtractOutputDir 'namedValues/rs-nv-secret/namedValueInformation.json' +$plainNamedValuePath = Join-Path $ExtractOutputDir 'namedValues/rs-nv-plain/namedValueInformation.json' + +foreach ($path in @($servicePolicyPath, $productPolicyPath, $apiPolicyPath, $operationPolicyPath, $resolverPolicyPath, $secretNamedValuePath, $plainNamedValuePath)) { + Assert-PathExists -Path $path +} + +Assert-Contains -Path $servicePolicyPath -Expected $redactionMarker +Assert-Contains -Path $servicePolicyPath -Expected $expectedBearerNamedValueReference +Assert-Contains -Path $servicePolicyPath -Expected '{{rs-nv-secret}}' +Assert-Contains -Path $servicePolicyPath -Expected 'InstrumentationKey=AI-INSTRUMENTATION-KEY-LITERAL' + +foreach ($literal in @( + 'SERVICE_AUTH_SECRET_LITERAL', + 'SERVICE_OCP_SECRET_LITERAL', + 'SERVICE_FUNCTIONS_KEY_LITERAL', + 'SERVICE_API_KEY_LITERAL', + 'SERVICE_QUERY_CODE_LITERAL', + 'SERVICE_QUERY_SIG_LITERAL', + 'SERVICE_QUERY_SUBSCRIPTION_LITERAL', + 'SERVICE_BASIC_PASSWORD_LITERAL', + 'SERVICE_CERT_BODY_LITERAL', + 'SERVICE_CERT_INLINE_LITERAL', + 'SERVICE_SIGNING_KEY_LITERAL', + 'SERVICE_DECRYPT_KEY_LITERAL', + 'SERVICE_ACCOUNT_KEY_LITERAL', + 'SERVICE_SHARED_ACCESS_KEY_LITERAL' +)) { + Assert-NotContains -Path $servicePolicyPath -Unexpected $literal +} + +Assert-Contains -Path $productPolicyPath -Expected $redactionMarker +Assert-NotContains -Path $productPolicyPath -Unexpected 'PRODUCT_AUTH_SECRET_LITERAL' + +Assert-Contains -Path $apiPolicyPath -Expected $redactionMarker +Assert-NotContains -Path $apiPolicyPath -Unexpected 'API_QUERY_CODE_LITERAL' + +Assert-Contains -Path $operationPolicyPath -Expected $redactionMarker +Assert-NotContains -Path $operationPolicyPath -Unexpected 'OPERATION_BASIC_PASSWORD_LITERAL' + +Assert-Contains -Path $resolverPolicyPath -Expected $redactionMarker +Assert-NotContains -Path $resolverPolicyPath -Unexpected 'RESOLVER_SHARED_ACCESS_KEY_LITERAL' + +$secretNamedValue = Get-Content -Path $secretNamedValuePath -Raw | ConvertFrom-Json +if ($secretNamedValue.properties.secret -ne $true) { + throw "Expected secret named value flag to remain true in $secretNamedValuePath" +} +if ($secretNamedValue.properties.value -ne $redactionMarker) { + throw "Expected secret named value to be redacted in $secretNamedValuePath" +} + +$plainNamedValue = Get-Content -Path $plainNamedValuePath -Raw | ConvertFrom-Json +if ($plainNamedValue.properties.value -ne 'plain-value') { + throw "Expected plain named value to remain unchanged in $plainNamedValuePath" +} + +Write-Host "โœ… Redaction checks passed" +exit 0 diff --git a/tests/integration/redact-secrets/phases/run-phase4-teardown.ps1 b/tests/integration/redact-secrets/phases/run-phase4-teardown.ps1 new file mode 100644 index 00000000..4684e516 --- /dev/null +++ b/tests/integration/redact-secrets/phases/run-phase4-teardown.ps1 @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +#requires -Version 7.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$SourceResourceGroup, + + [string]$SourceSubscriptionId, + + [string]$Location = 'centralus', + + [switch]$SkipTeardown +) + +$ErrorActionPreference = 'Stop' + +$integrationRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$maskingModule = Join-Path (Join-Path $integrationRoot 'shared/modules') 'LogMasking.psm1' +Import-Module $maskingModule -Force + +if ($SkipTeardown) { + Write-Host "โญ๏ธ Teardown skipped (-SkipTeardown)" + exit 0 +} + +function Get-SubscriptionArgs { + param([string]$SubscriptionId) + if ([string]::IsNullOrWhiteSpace($SubscriptionId)) { return @() } + return @('--subscription', $SubscriptionId) +} + +function Get-GroupExists { + param([string]$ResourceGroup, [string]$SubscriptionId) + $exists = az group exists --name $ResourceGroup @(Get-SubscriptionArgs -SubscriptionId $SubscriptionId) -o tsv 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Failed to check resource group existence for '$(Protect-ResourceGroupName -Value $ResourceGroup)'." + } + return $exists -eq 'true' +} + +function Wait-ForResourceGroupDeletion { + param([string]$ResourceGroup, [string]$SubscriptionId, [int]$TimeoutMinutes = 60) + + $timeoutSeconds = $TimeoutMinutes * 60 + $elapsed = 0 + while ($elapsed -lt $timeoutSeconds) { + if (-not (Get-GroupExists -ResourceGroup $ResourceGroup -SubscriptionId $SubscriptionId)) { + return + } + + Write-Host " ... waiting for deletion of $(Protect-ResourceGroupName -Value $ResourceGroup) (${elapsed}s elapsed)" + Start-Sleep -Seconds 30 + $elapsed += 30 + } + + throw "Timed out waiting for deletion of resource group '$(Protect-ResourceGroupName -Value $ResourceGroup)'." +} + +function Wait-ForDeletedApimService { + param([string]$ServiceName, [string]$ServiceLocation) + + $elapsed = 0 + while ($elapsed -lt 1800) { + az apim deletedservice show --service-name $ServiceName --location $ServiceLocation -o none 2>$null + if ($LASTEXITCODE -eq 0) { + return + } + + Start-Sleep -Seconds 15 + $elapsed += 15 + } + + throw "Timed out waiting for soft-deleted APIM service '$(Protect-ApimName -Value $ServiceName)'." +} + +Write-Host "๐Ÿงน PHASE 4 โ€” Teardown" + +$listArgs = @('apim', 'list', '--resource-group', $SourceResourceGroup, '--query', '[0].name', '-o', 'tsv') + (Get-SubscriptionArgs -SubscriptionId $SourceSubscriptionId) +$sourceApimName = az @listArgs 2>$null + +if (Get-GroupExists -ResourceGroup $SourceResourceGroup -SubscriptionId $SourceSubscriptionId) { + az group delete --name $SourceResourceGroup --yes --no-wait @(Get-SubscriptionArgs -SubscriptionId $SourceSubscriptionId) 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Failed to start deletion for source resource group '$(Protect-ResourceGroupName -Value $SourceResourceGroup)'." + } + + Wait-ForResourceGroupDeletion -ResourceGroup $SourceResourceGroup -SubscriptionId $SourceSubscriptionId +} + +if (-not [string]::IsNullOrWhiteSpace($sourceApimName)) { + Wait-ForDeletedApimService -ServiceName $sourceApimName -ServiceLocation $Location + az apim deletedservice purge --service-name $sourceApimName --location $Location @(Get-SubscriptionArgs -SubscriptionId $SourceSubscriptionId) 2>$null +} + +Write-Host "๐Ÿงน Teardown complete" +exit 0 diff --git a/tests/integration/redact-secrets/run-redact-secrets-test.ps1 b/tests/integration/redact-secrets/run-redact-secrets-test.ps1 new file mode 100644 index 00000000..94390cf9 --- /dev/null +++ b/tests/integration/redact-secrets/run-redact-secrets-test.ps1 @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +#requires -Version 7.0 + +[CmdletBinding()] +param( + [string]$SourceResourceGroup, + + [string]$SourceApimName, + + [string]$SourceSubscriptionId, + + [ValidateSet('Developer', 'Premium', 'Standard', 'BasicV2', 'StandardV2', 'PremiumV2')] + [string]$SkuName = 'StandardV2', + + [string]$Location = 'centralus', + + [ValidateSet('Info', 'Verbose', 'Debug')] + [string]$LogLevel = 'Verbose', + + [Parameter(Mandatory)] + [string]$PublisherEmail, + + [string]$ExtractOutputDir = "$PSScriptRoot/phases/extracted-artifacts", + + [switch]$SkipTeardown +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$integrationRoot = Split-Path $PSScriptRoot -Parent +$sharedScriptRuntimeModule = Join-Path $integrationRoot 'shared/modules/ScriptRuntime.psm1' +Import-Module $sharedScriptRuntimeModule -Force + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$random = -join ((97..122) | Get-Random -Count 3 | ForEach-Object { [char]$_ }) +$uniqueId = "$timestamp-$random" + +if (-not $SourceResourceGroup) { + $SourceResourceGroup = "bvt-$uniqueId-redact-rg" +} +if (-not $SourceApimName) { + $SourceApimName = "bvt-$timestamp$random-redact-apim" +} +if (-not $SourceSubscriptionId) { + $SourceSubscriptionId = $env:SOURCE_SUBSCRIPTION_ID +} + +$extractOutputDirValue = Get-BoundParameterValueOrNull -BoundParameters $PSBoundParameters -Name 'ExtractOutputDir' + +$phase1Script = Join-Path $PSScriptRoot 'phases/run-phase1-deploy.ps1' +$phase2Script = Join-Path $PSScriptRoot 'phases/run-phase2-extract.ps1' +$phase3Script = Join-Path $PSScriptRoot 'phases/run-phase3-validate-redaction.ps1' +$phase4Script = Join-Path $PSScriptRoot 'phases/run-phase4-teardown.ps1' + +foreach ($requiredFile in @($phase1Script, $phase2Script, $phase3Script, $phase4Script)) { + if (-not (Test-Path $requiredFile)) { + Write-Error "Required file not found: $requiredFile" + exit 2 + } +} + +$exitCode = 0 +$currentPhase = 'phase-setup' + +try { + $currentPhase = 'phase1-deploy' + $phase1Args = @{ + ResourceGroupName = $SourceResourceGroup + ApimName = $SourceApimName + PublisherEmail = $PublisherEmail + SkuName = $SkuName + Location = $Location + LogLevel = $LogLevel + } + Add-ArgumentIfSet -Hashtable $phase1Args -Key 'SubscriptionId' -Value $SourceSubscriptionId + $phase1Output = & $phase1Script @phase1Args + + if (-not $phase1Output) { + throw 'Phase 1 did not return deployment outputs.' + } + + $SourceSubscriptionId = $phase1Output.sourceSubscriptionId + $SourceResourceGroup = $phase1Output.sourceResourceGroup + $SourceApimName = $phase1Output.sourceApimName + $SkuName = $phase1Output.skuName + $Location = $phase1Output.location + + $currentPhase = 'phase2-extract' + $phase2Args = @{ + SourceResourceGroup = $SourceResourceGroup + SourceApimName = $SourceApimName + SourceSubscriptionId = $SourceSubscriptionId + LogLevel = $LogLevel + } + Add-ArgumentIfSet -Hashtable $phase2Args -Key 'ExtractOutputDir' -Value $extractOutputDirValue + & $phase2Script @phase2Args | Out-Null + + $currentPhase = 'phase3-validate-redaction' + $phase3Args = @{ + LogLevel = $LogLevel + } + Add-ArgumentIfSet -Hashtable $phase3Args -Key 'ExtractOutputDir' -Value $extractOutputDirValue + & $phase3Script @phase3Args +} +catch { + Write-Host "โŒ Redaction integration test failed during $currentPhase" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Red + + if ($currentPhase -eq 'phase3-validate-redaction') { + $exitCode = 1 + } else { + $exitCode = 2 + } +} +finally { + $phase4Args = @{ + SourceResourceGroup = $SourceResourceGroup + Location = $Location + SkipTeardown = $SkipTeardown + } + Add-ArgumentIfSet -Hashtable $phase4Args -Key 'SourceSubscriptionId' -Value $SourceSubscriptionId + & $phase4Script @phase4Args +} + +exit $exitCode diff --git a/tests/integration/shared/modules/ApiopsCli.psm1 b/tests/integration/shared/modules/ApiopsCli.psm1 new file mode 100644 index 00000000..131f44c8 --- /dev/null +++ b/tests/integration/shared/modules/ApiopsCli.psm1 @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS +Maps script log levels to apiops CLI log levels. + +.PARAMETER ScriptLogLevel +Script log level (Info, Verbose, Debug). + +.OUTPUTS +System.String +#> +function Get-ApiopsLogLevel { + param( + [Parameter(Mandatory)] + [string]$ScriptLogLevel + ) + + switch ($ScriptLogLevel) { + 'Info' { return 'info' } + 'Verbose' { return 'warn' } + 'Debug' { return 'debug' } + default { return 'info' } + } +} + +<# +.SYNOPSIS +Builds optional auth args from OIDC environment variables. + +.OUTPUTS +System.String[] +#> +function Get-ApiopsAuthArgs { + $authArgs = @() + + if (-not [string]::IsNullOrWhiteSpace($env:AZURE_CLIENT_ID)) { + $authArgs += @('--client-id', $env:AZURE_CLIENT_ID) + } + + if (-not [string]::IsNullOrWhiteSpace($env:AZURE_TENANT_ID)) { + $authArgs += @('--tenant-id', $env:AZURE_TENANT_ID) + } + + return $authArgs +} + +Export-ModuleMember -Function Get-ApiopsLogLevel, Get-ApiopsAuthArgs \ No newline at end of file diff --git a/tests/integration/shared/modules/DeploymentOps.psm1 b/tests/integration/shared/modules/DeploymentOps.psm1 new file mode 100644 index 00000000..15b786eb --- /dev/null +++ b/tests/integration/shared/modules/DeploymentOps.psm1 @@ -0,0 +1,216 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +Import-Module (Join-Path $PSScriptRoot 'LogMasking.psm1') + +<# +.SYNOPSIS +Prints masked details for failed ARM deployment operations. + +.PARAMETER ResourceGroupName +Resource group that contains the deployment. + +.PARAMETER DeploymentName +Deployment name to inspect. + +.PARAMETER Replacements +Mask replacement map for output. + +.OUTPUTS +None +#> +function Write-DeploymentFailureDetails { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$DeploymentName, + [hashtable]$Replacements = @{} + ) + + $maskedRg = Protect-ResourceGroupName -Value $ResourceGroupName + Write-Host "" + Write-Host "========== Deployment failure details ==========" -ForegroundColor Yellow + Write-Host "Resource group: $maskedRg" -ForegroundColor Yellow + Write-Host "Deployment: $DeploymentName" -ForegroundColor Yellow + Write-Host "Querying failed deployment operations (before teardown)..." -ForegroundColor Yellow + + $query = "[?properties.provisioningState=='Failed'].{resource:properties.targetResource.resourceName, type:properties.targetResource.resourceType, code:properties.statusMessage.error.code, message:properties.statusMessage.error.message, details:properties.statusMessage.error.details}" + + try { + Invoke-MaskedProcess -FilePath 'az' -Replacements $Replacements -Arguments @( + 'deployment', 'operation', 'group', 'list', + '--resource-group', $ResourceGroupName, + '--name', $DeploymentName, + '--query', $query, + '--output', 'json' + ) + } catch { + $maskedErr = Protect-LogLine -Line ($_.Exception.Message) -Replacements $Replacements + Write-Host "Failed to retrieve deployment operations: $maskedErr" -ForegroundColor Red + } + + Write-Host "================================================" -ForegroundColor Yellow +} + +<# + .SYNOPSIS + Polls a condition until it succeeds or times out. + + .PARAMETER Probe + Scriptblock invoked on each poll. Return $true when the condition is met. + + .PARAMETER TimeoutSeconds + Maximum wait time in seconds. + + .PARAMETER PollIntervalSeconds + Polling interval in seconds. + + .PARAMETER TimeoutMessage + Error message thrown when the timeout is reached. + + .OUTPUTS + System.Boolean + #> + function Invoke-PolledWait { + [CmdletBinding()] + param( + [Parameter(Mandatory)][scriptblock]$Probe, + [int]$TimeoutSeconds = 300, + [int]$PollIntervalSeconds = 10, + [Parameter(Mandatory)][string]$TimeoutMessage + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (& $Probe) { + return $true + } + + Start-Sleep -Seconds $PollIntervalSeconds + } + + throw $TimeoutMessage + } + + <# +.SYNOPSIS +Waits for an APIM instance to reach Succeeded provisioning. + +.PARAMETER ResourceGroupName +Resource group containing the APIM instance. + +.PARAMETER ApimName +APIM service name. + +.PARAMETER TimeoutSeconds +Maximum wait time in seconds. + +.PARAMETER PollIntervalSeconds +Polling interval in seconds. + +.OUTPUTS +System.Boolean +#> +function Wait-ApimActivation { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$ApimName, + [int]$TimeoutSeconds = 1800, + [int]$PollIntervalSeconds = 20 + ) + + $maskedApim = Protect-ApimName -Value $ApimName + Write-Host "Waiting for APIM '$maskedApim' to finish Activating (timeout ${TimeoutSeconds}s)..." -ForegroundColor Cyan + + $activationState = @{ LastState = $null } + $probe = { + $state = az apim show --resource-group $ResourceGroupName --name $ApimName --query provisioningState --output tsv 2>$null + if ($state -ne $activationState.LastState) { + Write-Host " provisioningState: $state" -ForegroundColor Gray + $activationState.LastState = $state + } + if ($state -eq 'Succeeded') { return $true } + if ($state -eq 'Failed') { throw "APIM '$maskedApim' entered Failed state during activation wait" } + return $false + } + + Invoke-PolledWait ` + -Probe $probe ` + -TimeoutSeconds $TimeoutSeconds ` + -PollIntervalSeconds $PollIntervalSeconds ` + -TimeoutMessage "APIM '$maskedApim' did not reach Succeeded within ${TimeoutSeconds}s (last state: $($activationState.LastState))" +} + +<# +.SYNOPSIS +Waits for an APIM API to become queryable via az CLI. + +.PARAMETER ResourceGroupName +Resource group containing the APIM instance. + +.PARAMETER ApimServiceName +APIM service name. + +.PARAMETER ApiId +The API ID to query. + +.PARAMETER Replacements +Mask replacement map for output. + +.PARAMETER TimeoutSeconds +Maximum wait time in seconds. + +.PARAMETER PollIntervalSeconds +Polling interval in seconds. + +.OUTPUTS +System.Boolean +#> +function Wait-ApimApiQueryable { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$ApimServiceName, + [Parameter(Mandatory)][string]$ApiId, + [hashtable]$Replacements = @{}, + [int]$TimeoutSeconds = 300, + [int]$PollIntervalSeconds = 10 + ) + + $maskedApim = Protect-ApimName -Value $ApimServiceName + Write-Host "Waiting for APIM API '$ApiId' to become queryable in '$maskedApim' (timeout ${TimeoutSeconds}s)..." -ForegroundColor Cyan + + $subscriptionId = az account show --query id --output tsv 2>$null + if ([string]::IsNullOrWhiteSpace($subscriptionId)) { + throw "Could not resolve active Azure subscription while waiting for API '$ApiId'" + } + + $apiListUri = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.ApiManagement/service/$ApimServiceName/apis?api-version=2025-09-01-preview" + + $probe = { + $probeArgs = @( + 'rest', + '--method', 'get', + '--uri', $apiListUri, + '--query', "length(value[?name=='$ApiId'])", + '--output', 'tsv' + ) + $probeResult = Invoke-MaskedAzCommand -Replacements $Replacements -Arguments $probeArgs + if ($LASTEXITCODE -eq 0 -and "$probeResult" -eq '1') { + return $true + } + + return $false + } + + Invoke-PolledWait ` + -Probe $probe ` + -TimeoutSeconds $TimeoutSeconds ` + -PollIntervalSeconds $PollIntervalSeconds ` + -TimeoutMessage "Timed out waiting for API '$ApiId' to be queryable in APIM '$maskedApim' within ${TimeoutSeconds}s" +} + +Export-ModuleMember -Function ` + Write-DeploymentFailureDetails, ` + Wait-ApimActivation, ` + Wait-ApimApiQueryable diff --git a/tests/integration/shared/modules/LogMasking.psm1 b/tests/integration/shared/modules/LogMasking.psm1 new file mode 100644 index 00000000..15e11ef4 --- /dev/null +++ b/tests/integration/shared/modules/LogMasking.psm1 @@ -0,0 +1,447 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +# MaskingHelpers โ€” secret-redaction utilities for the round-trip integration test scripts. + +$script:EnableMasking = $true + +$script:BuiltinRedactions = @( + @{ Pattern = '([?&])(t|c|s|h)=[^&''"\s]+' + Replacement = '$1$2=' } + + @{ Pattern = '/subscriptions/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + Replacement = '/subscriptions/' } + + @{ Pattern = '/(operationStatuses|operationResults)/[A-Za-z0-9._-]{10,}' + Replacement = '/$1/' } + + @{ Pattern = "(?i)(['""]?x-ms-(?:correlation-)?(?:request|client-request)-id['""]?\s*[:=]\s*['""]?)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + Replacement = '$1' } + + @{ Pattern = "(?i)(['""]?x-ms-routing-request-id['""]?\s*[:=]\s*['""]?)[A-Z0-9]+:\d{8}T\d{6}Z:[0-9a-fA-F-]{36}" + Replacement = '$1' } + + @{ Pattern = "(?i)(authorization[:\s=]+bearer\s+)[A-Za-z0-9._\-+/=]+" + Replacement = '$1' } + + @{ Pattern = '\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}' + Replacement = '' } + + @{ Pattern = '[A-Za-z0-9](?:[A-Za-z0-9._%+\-]*[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9.\-]*[A-Za-z0-9])?\.[A-Za-z]{2,}' + Replacement = '' } +) + +<# +.SYNOPSIS +Masks generic identifiers while preserving short prefix/suffix. + +.PARAMETER Value +Input string to mask. + +.PARAMETER Prefix +Visible prefix length. + +.PARAMETER Suffix +Visible suffix length. + +.OUTPUTS +System.String +#> +function Protect-Identifier { + param( + [string]$Value, + [int]$Prefix = 6, + [int]$Suffix = 4 + ) + + if (-not $script:EnableMasking) { + return $Value + } + + if ([string]::IsNullOrWhiteSpace($Value)) { + return '' + } + + if ($Value.Length -le ($Prefix + $Suffix)) { + return '' + } + + return "{0}...{1}" -f $Value.Substring(0, $Prefix), $Value.Substring($Value.Length - $Suffix) +} + +<# +.SYNOPSIS +Replaces subscription IDs with a stable redaction token. + +.PARAMETER Value +Subscription ID value. + +.OUTPUTS +System.String +#> +function Protect-SubscriptionId { + param([string]$Value) + if (-not $script:EnableMasking) { return $Value } + return '' +} + +<# +.SYNOPSIS +Masks resource group names with minimal context retained. + +.PARAMETER Value +Resource group name. + +.OUTPUTS +System.String +#> +function Protect-ResourceGroupName { + param([string]$Value) + + if (-not $script:EnableMasking) { return $Value } + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + + # Preserve the generated suffix for round-trip RGs: --