diff --git a/.github/skills/integration-test-prerequisites/SKILL.md b/.github/skills/integration-test-prerequisites/SKILL.md index 07f2161c..1da7e5ff 100644 --- a/.github/skills/integration-test-prerequisites/SKILL.md +++ b/.github/skills/integration-test-prerequisites/SKILL.md @@ -1,6 +1,6 @@ --- name: "integration-test-prerequisites" -description: "Set up Azure and GitHub prerequisites for the integration-test workflow using a user-assigned managed identity, OIDC federated credentials, RBAC roles, and environment secrets. Use when troubleshooting AADSTS70025/AADSTS700213 or authorization failures during integration-test workflow runs." +description: "Set up Azure and GitHub prerequisites for integration workflows using a user-assigned managed identity, OIDC federated credentials, RBAC roles, and environment secrets. Use when troubleshooting AADSTS70025/AADSTS700213 or authorization failures during integration-test or integration-redact-secrets workflow runs." domain: "ci-cd" confidence: "high" source: "manual + observed from integration-test OIDC and RBAC troubleshooting" @@ -8,11 +8,14 @@ source: "manual + observed from integration-test OIDC and RBAC troubleshooting" ## Context -Use this skill when preparing or repairing prerequisites for `.github/workflows/integration-test.yml`. +Use this skill when preparing or repairing prerequisites for: -This workflow expects: +- `.github/workflows/integration-test.yml` +- `.github/workflows/integration-redact-secrets.yml` + +These workflows expect: - OIDC login through `azure/login@v2` -- GitHub environment `integration-test` +- GitHub environment `integration-test` (shared by both workflows) - Azure identity with enough permissions to deploy resources and create role assignments in test resource groups Preferred identity model: user-assigned managed identity (UAMI). 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/.gitignore b/.gitignore index dd31a559..a807d47b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,11 +39,11 @@ Desktop.ini .local-extract*/ # Files for integration tests -tests/integration/all-resource-types/**/logs/** -tests/integration/all-resource-types/**/extracted-artifacts*/** -tests/integration/all-resource-types/bicep/target-apim.json -tests/integration/all-resource-types/bicep/source-apim.json -tests/integration/all-resource-types/bicep/source-apim-post-activation.bicep +tests/integration/**/logs +tests/integration/**/extracted-artifacts* +tests/integration/**/bicep/target-apim.json +tests/integration/**/bicep/source-apim.json +tests/integration/**/bicep/source-apim-post-activation.bicep # Environment variables .env diff --git a/.squad/agents/apimexpert/history.md b/.squad/agents/apimexpert/history.md index 29ff4713..00aea632 100644 --- a/.squad/agents/apimexpert/history.md +++ b/.squad/agents/apimexpert/history.md @@ -116,3 +116,17 @@ When comparing a *link-imported* API (e.g. Petstore via `swagger-link`/`openapi- Round-trip comparison harness (`tests/integration/all-resource-types`) normalizes both via `RepresentationSchemaRefIgnoredProperties` and `ParameterIgnoredProperties`. Symptom if not stripped: every operation shows a `properties.request/responses/templateParameters` DIFF present-on-one-side-only. +### 2026-07-01: Overriding a policy to clear a redacted secret — possible but rarely the right fix + +**Question that comes up:** when the extract-time secret redactor leaves `*** REDACTED ***` inline in a policy, can you clear the publish pre-flight guard by *overriding the policy* instead of fixing the source? + +**Answer: yes, technically — but it's almost never the intended path.** + +- All five policy types are wired into the override system (`src/services/override-merger.ts`): `ServicePolicy → policies`, `PolicyFragment → policyFragments` (direct); `ApiPolicy → apis..policies`, `ProductPolicy → products..policies` (child); `ApiOperationPolicy → apis..operations..policies` (grandchild). +- The publish payload for a policy is `{ properties: { value, format } }`, and `applyOverrides` deep-merges `properties`, so an override supplying `properties.value` replaces the policy XML wholesale. +- The pre-flight guard (`src/services/secret-redaction-guard.ts`) applies overrides **before** scanning for the marker — by design — so a policy override that yields clean content passes the check. + +**Why it's the wrong tool for redacted secrets:** +- The marker is inserted **inline** inside the XML (e.g. inside a `set-header` ``), but overrides are **whole-value** replacements of `properties.value` — there is no inline/sub-string patch. You'd have to paste the entire policy XML (with the real secret) into a committed override file, re-introducing the plaintext secret that redaction removed. +- Intended remediation: change the **source** policy to reference a named value (`{{my-secret}}`) so redaction never triggers, then supply the secret via a named-value override or Key Vault reference. The docs' "Gotcha: Redacted secrets" section (`docs/guides/environment-overrides.md`) only covers named values — there is no documented "override a redacted policy" workflow, reflecting this. + diff --git a/src/services/api-extractor.ts b/src/services/api-extractor.ts index cd9cdd67..961e3a21 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 { redactAndWarnPolicySecrets } 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,15 @@ async function extractApiPolicy( const policyContent = properties?.value as string | undefined; if (policyContent) { + const redactedContent = redactAndWarnPolicySecrets(policyDescriptor, policyContent); await store.writeContent( outputDir, policyDescriptor, - policyContent, + redactedContent, 'policy' ); logger.debug(`Extracted ${buildResourceLabel(policyDescriptor)}`); - return policyContent; + return redactedContent; } return undefined; @@ -420,13 +422,14 @@ async function extractApiOperations( const policyContent = properties?.value as string | undefined; if (policyContent) { - await store.writeContent(outputDir, opPolicyDescriptor, policyContent, 'policy'); + const redactedContent = redactAndWarnPolicySecrets(opPolicyDescriptor, policyContent); + 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 +534,14 @@ async function extractGraphQLResolvers( const policyContent = props?.value as string | undefined; if (policyContent) { - await store.writeContent(outputDir, resolverPolicyDescriptor, policyContent, 'policy'); + const redactedContent = redactAndWarnPolicySecrets(resolverPolicyDescriptor, policyContent); + 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..e39e1245 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 { redactAndWarnPolicySecrets } 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,11 @@ async function extractServicePolicy( const policyContent = properties?.value as string | undefined; if (policyContent) { - await store.writeContent(outputDir, descriptor, policyContent, 'policy'); + const redactedContent = redactAndWarnPolicySecrets(descriptor, policyContent); + 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..6d2aa0a9 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 { redactAndWarnPolicySecrets } 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,10 @@ async function extractProductPolicy( const policyContent = properties?.value as string | undefined; if (policyContent) { - await store.writeContent(outputDir, policyDescriptor, policyContent, 'policy'); + const redactedContent = redactAndWarnPolicySecrets(policyDescriptor, policyContent); + 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/publish-service.ts b/src/services/publish-service.ts index 3effbb70..121d5639 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -26,6 +26,8 @@ import { publishProduct } from './product-publisher.js'; import { generateDryRunReport, DryRunReport } from './dry-run-reporter.js'; import { computeDeleteActions } from './delete-unmatched-service.js'; import { computeGitDiff } from './git-diff-service.js'; +import { scanForRedactionMarkers } from './secret-redaction-guard.js'; +import { REDACTION_MARKER } from './secret-redactor.js'; /** * The APIM Backend properties.type value that identifies a pool backend. @@ -79,6 +81,44 @@ export async function runPublish( `Publishing ${targetDescriptors.length} resources (dry-run: ${config.dryRun})` ); + // Step 1b: Redaction pre-flight gate. + // Scan every artifact that would be published for leftover redaction markers + // ('*** REDACTED ***'). A single leftover marker aborts the ENTIRE publish + // before any PUT is issued — and also fails dry-run — so a service can never + // be left partially published with placeholder secrets. + const redactionFindings = await scanForRedactionMarkers( + store, + config, + targetDescriptors + ); + if (redactionFindings.length > 0) { + logger.error( + `Publish aborted: ${redactionFindings.length} artifact(s) still contain the redaction marker '${REDACTION_MARKER}'. ` + + 'Replace inline secrets with named values or KeyVault references before publishing: ' + + 'https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-properties' + ); + const actions: PublishActionResult[] = redactionFindings.map((finding) => { + logger.error(` - ${finding.label} (${finding.location})`); + return { + descriptor: finding.descriptor, + action: 'noop', + status: 'failed', + error: new Error( + `${finding.label} contains '${REDACTION_MARKER}' in ${finding.location}` + ), + }; + }); + + return { + totalPuts: 0, + totalDeletes: 0, + totalErrors: actions.length, + totalSkipped: 0, + exitCode: EXIT_FATAL, + actions, + }; + } + // Step 2: Handle dry-run mode if (config.dryRun) { const dryRunReport = await generateDryRunReport( diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index d09e308e..f41d0473 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; @@ -32,7 +33,7 @@ export interface ResourcePublishResult { /** * Policy resource types that have external XML content */ -const POLICY_TYPES = new Set([ +export const POLICY_TYPES = new Set([ ResourceType.ServicePolicy, ResourceType.ProductPolicy, ResourceType.ApiPolicy, @@ -416,6 +417,9 @@ 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. const payload: Record = { properties: { value: policyContent.content, @@ -426,6 +430,19 @@ async function publishPolicy( // Apply overrides (e.g., format: xml) before PUT — matches Toolkit behavior const mergedPayload = applyOverrides(descriptor, payload, config.overrides); + // Marker check runs AFTER overrides: an override may legitimately replace the + // policy value with clean content, so only the merged (about-to-be-published) + // value is authoritative for redaction detection. + const mergedProps = mergedPayload.properties as Record | undefined; + const mergedValue = mergedProps?.value; + if (typeof mergedValue === 'string' && mergedValue.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' + ); + } + await client.putResource(context, descriptor, mergedPayload); return { diff --git a/src/services/secret-redaction-guard.ts b/src/services/secret-redaction-guard.ts new file mode 100644 index 00000000..cad1351a --- /dev/null +++ b/src/services/secret-redaction-guard.ts @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +/** + * Secret redaction pre-flight guard + * + * Scans the set of artifacts that are about to be published for leftover + * redaction markers ('*** REDACTED ***'). Any finding aborts the entire + * publish before a single PUT is issued (for both real and dry-run modes), + * so a partially-published service can never result from placeholder secrets. + * + * Detection mirrors the per-resource publish guards: + * - Policies: build the publish payload, apply overrides, then check the + * merged `properties.value` for the marker (an override may legitimately + * replace the value with clean content). + * - Named values: read the resource, apply overrides; KeyVault-backed values + * have their `value` stripped before publish and are therefore ignored; + * otherwise a `secret === true` value that exactly equals the marker is a + * finding. + */ + +import type { IArtifactStore } from '../clients/iartifact-store.js'; +import type { PublishConfig } from '../models/config.js'; +import type { ResourceDescriptor } from '../models/types.js'; +import { ResourceType } from '../models/resource-types.js'; +import { applyOverrides } from './override-merger.js'; +import { POLICY_TYPES } from './resource-publisher.js'; +import { REDACTION_MARKER } from './secret-redactor.js'; +import { buildResourceLabel } from '../lib/resource-uri.js'; + +/** + * A single artifact that still contains a redaction marker after overrides. + */ +export interface RedactionMarkerFinding { + /** The descriptor for the offending artifact. */ + descriptor: ResourceDescriptor; + /** Human-readable resource label (e.g. `apis/echo/policies/policy`). */ + label: string; + /** Where the marker was found (e.g. `policy.xml`, `properties.value`). */ + location: string; +} + +/** + * Scan the supplied descriptors for leftover redaction markers in the content + * that would actually be published (i.e. after overrides are applied). + * + * Returns every finding so the caller can report all offenders at once rather + * than failing on the first one. + */ +export async function scanForRedactionMarkers( + store: IArtifactStore, + config: PublishConfig, + descriptors: ResourceDescriptor[] +): Promise { + const findings: RedactionMarkerFinding[] = []; + + for (const descriptor of descriptors) { + if (POLICY_TYPES.has(descriptor.type)) { + const finding = await scanPolicy(store, config, descriptor); + if (finding) findings.push(finding); + } else if (descriptor.type === ResourceType.NamedValue) { + const finding = await scanNamedValue(store, config, descriptor); + if (finding) findings.push(finding); + } + } + + return findings; +} + +async function scanPolicy( + store: IArtifactStore, + config: PublishConfig, + descriptor: ResourceDescriptor +): Promise { + const policyContent = await store.readContent(config.sourceDir, descriptor, 'policy'); + if (!policyContent) { + return undefined; + } + + const payload: Record = { + properties: { + value: policyContent.content, + format: 'rawxml', + }, + }; + + const merged = applyOverrides(descriptor, payload, config.overrides); + const mergedProps = merged.properties as Record | undefined; + const mergedValue = mergedProps?.value; + + if (typeof mergedValue === 'string' && mergedValue.includes(REDACTION_MARKER)) { + return { + descriptor, + label: buildResourceLabel(descriptor), + location: 'policy.xml', + }; + } + + return undefined; +} + +async function scanNamedValue( + store: IArtifactStore, + config: PublishConfig, + descriptor: ResourceDescriptor +): Promise { + const json = await store.readResource(config.sourceDir, descriptor); + if (!json) { + return undefined; + } + + const merged = applyOverrides(descriptor, json, config.overrides); + const props = merged.properties as Record | undefined; + + // KeyVault-backed named values have `properties.value` stripped before publish, + // so any marker still present in the file is never sent to APIM. + if (props?.keyVault != null) { + return undefined; + } + + if (props?.secret === true && props.value === REDACTION_MARKER) { + return { + descriptor, + label: buildResourceLabel(descriptor), + location: 'properties.value', + }; + } + + return undefined; +} diff --git a/src/services/secret-redactor.ts b/src/services/secret-redactor.ts index 33631373..12e4eb7c 100644 --- a/src/services/secret-redactor.ts +++ b/src/services/secret-redactor.ts @@ -10,9 +10,32 @@ 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', +]); +const BEARER_TOKEN_PATTERN = /^(\s*Bearer)(\s+)(.*?)(\s*)$/i; + +export interface PolicySecretFinding { + location: string; +} /** * Redact secret values from a resource's JSON payload. @@ -56,3 +79,207 @@ 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); +} + +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. + */ +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) => { + 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}${redactedValue}${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, + }; +} + +/** + * Redact inline literal secrets in policy XML content and emit a warning log + * for every finding. This is the entry point services should use so that + * redaction and warning always happen together; the underlying + * {@link redactPolicySecrets} (pure, exported) and `warnPolicySecretRedactions` + * (private) helpers stay separate for testability. + */ +export function redactAndWarnPolicySecrets( + descriptor: ResourceDescriptor, + policyContent: string +): string { + const { redactedContent, findings } = redactPolicySecrets(policyContent); + warnPolicySecretRedactions(descriptor, findings); + return redactedContent; +} + +/** + * Emit warning logs for policy secret redaction findings. + */ +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/integration/all-resource-types/phases/run-phase7-teardown.ps1 b/tests/integration/all-resource-types/phases/run-phase7-teardown.ps1 index 80447453..60ea771a 100644 --- a/tests/integration/all-resource-types/phases/run-phase7-teardown.ps1 +++ b/tests/integration/all-resource-types/phases/run-phase7-teardown.ps1 @@ -42,7 +42,10 @@ param( $ErrorActionPreference = 'Stop' $maskingModule = Join-Path (Split-Path $PSScriptRoot -Parent) 'modules/LogMasking.psm1' +$integrationRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$sharedDeploymentOpsModule = Join-Path (Join-Path $integrationRoot 'shared/modules') 'DeploymentOps.psm1' Import-Module $maskingModule -Force +Import-Module $sharedDeploymentOpsModule -Force if ($SkipTeardown) { Write-Host "⏭️ Teardown skipped (-SkipTeardown)" @@ -64,22 +67,6 @@ function Get-SubscriptionArgs { return @('--subscription', $SubscriptionId) } -function Get-GroupExists { - param( - [Parameter(Mandatory)] - [string]$ResourceGroup, - [string]$SubscriptionId - ) - - $subArgs = Get-SubscriptionArgs -SubscriptionId $SubscriptionId - $exists = az group exists --name $ResourceGroup @subArgs -o tsv 2>$null - if ($LASTEXITCODE -ne 0) { - throw "Failed to query existence for resource group '$(Protect-ResourceGroupName -Value $ResourceGroup)'." - } - - return $exists -eq 'true' -} - $sourceListArgs = @('apim', 'list', '--resource-group', $SourceResourceGroup, '--query', '[0].name', '-o', 'tsv') + (Get-SubscriptionArgs -SubscriptionId $SourceSubscriptionId) $targetListArgs = @('apim', 'list', '--resource-group', $TargetResourceGroup, '--query', '[0].name', '-o', 'tsv') + (Get-SubscriptionArgs -SubscriptionId $TargetSubscriptionId) @@ -120,32 +107,6 @@ function Wait-ForResourceGroupsDeletion { throw "Timed out waiting for resource group deletion for: $($maskedGroups -join ', ')." } -function Wait-ForDeletedApimService { - param( - [Parameter(Mandatory)] - [string]$ServiceName, - [Parameter(Mandatory)] - [string]$ServiceLocation, - [int]$TimeoutMinutes = 30, - [int]$IntervalSeconds = 15 - ) - - $waitedSeconds = 0 - $timeoutSeconds = $TimeoutMinutes * 60 - - while ($waitedSeconds -lt $timeoutSeconds) { - az apim deletedservice show --service-name $ServiceName --location $ServiceLocation -o none 2>$null - if ($LASTEXITCODE -eq 0) { - return - } - - Write-Host " ... waiting for APIM soft-delete entry ($(Protect-ApimName -Value $ServiceName)) (${waitedSeconds}s elapsed)" - Start-Sleep -Seconds $IntervalSeconds - $waitedSeconds += $IntervalSeconds - } - - throw "Timed out waiting for APIM soft-delete entry for '$(Protect-ApimName -Value $ServiceName)' in location '$ServiceLocation'." -} Write-Host " Deleting $(Protect-ResourceGroupName -Value $SourceResourceGroup)..." if (Get-GroupExists -ResourceGroup $SourceResourceGroup -SubscriptionId $SourceSubscriptionId) { @@ -179,15 +140,15 @@ $groups = @( Wait-ForResourceGroupsDeletion -Groups $groups if ($sourceApimName) { - Wait-ForDeletedApimService -ServiceName $sourceApimName -ServiceLocation $Location + Wait-ForDeletedApimService -ServiceName $sourceApimName -ServiceLocation $Location -SubscriptionId $SourceSubscriptionId Write-Host " 🗑️ Purging soft-deleted APIM: $(Protect-ApimName -Value $sourceApimName)..." - az apim deletedservice purge --service-name $sourceApimName --location $Location 2>$null + az apim deletedservice purge --service-name $sourceApimName --location $Location @(Get-SubscriptionArgs -SubscriptionId $SourceSubscriptionId) 2>$null } if ($targetApimName) { - Wait-ForDeletedApimService -ServiceName $targetApimName -ServiceLocation $Location + Wait-ForDeletedApimService -ServiceName $targetApimName -ServiceLocation $Location -SubscriptionId $TargetSubscriptionId Write-Host " 🗑️ Purging soft-deleted APIM: $(Protect-ApimName -Value $targetApimName)..." - az apim deletedservice purge --service-name $targetApimName --location $Location 2>$null + az apim deletedservice purge --service-name $targetApimName --location $Location @(Get-SubscriptionArgs -SubscriptionId $TargetSubscriptionId) 2>$null } Write-Host "🧹 Teardown complete (hard-delete)" 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..97e6ef81 --- /dev/null +++ b/tests/integration/redact-secrets/bicep/source-apim-post-activation.bicep @@ -0,0 +1,173 @@ + +@description('APIM service name.') +param apimName string + +@description('Base64-encoded PFX payload for temporary APIM certificate used by service policy.') +@secure() +param tempCertificateData string + +@description('Password used to open temporary APIM certificate PFX payload.') +@secure() +param tempCertificatePassword string + +@description('Temporary APIM certificate entity name used by authentication-certificate policy.') +param tempCertificateName string = 'rs-temp-cert' + +var servicePolicyXmlTemplate = ''' + + + 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 + + + body=SERVICE_CERT_BODY_LITERAL;inline=SERVICE_CERT_INLINE_LITERAL + + SERVICE_SIGNING_KEY_LITERAL + SERVICE_DECRYPT_KEY_LITERAL + + DefaultEndpointsProtocol=https;AccountName=storageacct;AccountKey=SERVICE_ACCOUNT_KEY_LITERAL;EndpointSuffix=${environment().suffixes.storage} + Endpoint=sb://foo.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SERVICE_SHARED_ACCESS_KEY_LITERAL + InstrumentationKey=AI-INSTRUMENTATION-KEY-LITERAL + + + + + +''' + +// Multi-line (triple-quoted) Bicep strings do not support ${} interpolation, so +// resolve the certificate name token with replace(). +var servicePolicyXml = replace(servicePolicyXmlTemplate, '__TEMP_CERT_NAME__', tempCertificateName) + +var productPolicyXml = ''' + + + + Bearer PRODUCT_AUTH_SECRET_LITERAL + + + + + +''' + +var apiPolicyXml = ''' + + + + API_QUERY_CODE_LITERAL + + + + + +''' + +var operationPolicyXml = ''' + + + + + + + + + +''' + +var resolverPolicyXml = ''' + + + GET + https://example.org/graphql-resolver + Endpoint=sb://foo.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=RESOLVER_SHARED_ACCESS_KEY_LITERAL + + +''' + +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 tempPolicyCertificate 'Microsoft.ApiManagement/service/certificates@2025-09-01-preview' = { + parent: apim + name: tempCertificateName + properties: { + data: tempCertificateData + password: tempCertificatePassword + } +} + +resource servicePolicy 'Microsoft.ApiManagement/service/policies@2025-09-01-preview' = { + parent: apim + name: 'policy' + dependsOn: [ + tempPolicyCertificate + ] + 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..6c871825 --- /dev/null +++ b/tests/integration/redact-secrets/bicep/source-apim.bicep @@ -0,0 +1,151 @@ +@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' + description: 'GraphQL resolver used for redaction integration validation.' + 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..84fc7209 --- /dev/null +++ b/tests/integration/redact-secrets/phases/run-phase1-deploy.ps1 @@ -0,0 +1,277 @@ +# 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' + +function New-LocalJwtKeyBase64 { + $bytes = New-Object byte[] 32 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { + $rng.GetBytes($bytes) + } + finally { + $rng.Dispose() + } + + return [Convert]::ToBase64String($bytes) +} + +function New-TemporaryPfxPayload { + $subject = "CN=apiops-redact-temp-$([Guid]::NewGuid().ToString('N').Substring(0, 8))" + $password = "Pfx-$([Guid]::NewGuid().ToString('N'))!" + + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + try { + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $subject, + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + + $certificate = $request.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddMinutes(-5), + [System.DateTimeOffset]::UtcNow.AddDays(1) + ) + + try { + $pfxBytes = $certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $password) + } + finally { + $certificate.Dispose() + } + } + finally { + $rsa.Dispose() + } + + return [ordered]@{ + data = [Convert]::ToBase64String($pfxBytes) + password = $password + } +} + +function New-LocalSecretValue { + param( + [Parameter(Mandatory)] + [string]$Placeholder + ) + + switch ($Placeholder) { + 'SERVICE_SIGNING_KEY_LITERAL' { + return New-LocalJwtKeyBase64 + } + 'SERVICE_DECRYPT_KEY_LITERAL' { + return New-LocalJwtKeyBase64 + } + default { + return "rs-$([Guid]::NewGuid().ToString('N'))" + } + } +} + +function Remove-PlaintextTempFile { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + if (-not (Test-Path $Path)) { + return + } + + try { + $fileInfo = Get-Item -Path $Path -ErrorAction Stop + if ($fileInfo.Length -gt 0) { + $wipeBytes = New-Object byte[] $fileInfo.Length + [System.IO.File]::WriteAllBytes($Path, $wipeBytes) + } + } + catch { + # Best-effort wipe; always attempt deletion next. + } + finally { + Remove-Item -Path $Path -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "🚀 PHASE 1 — Deploy source APIM for redaction test" +Write-Host " Azure subscription: $(Protect-SubscriptionId -Value $resolvedSubscriptionId)" +Write-Host "Resource group: $(Protect-ResourceGroupName -Value $ResourceGroupName) | SKU: $SkuName | Location: $Location" + +Write-Verbose " → Creating resource group" +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' +$postActivationTemplateRaw = Get-Content -Path $postActivationBicepFile -Raw +$postActivationTemplateResolved = $postActivationTemplateRaw +$literalPattern = '\b[A-Z0-9_]+_LITERAL\b' +$literals = [System.Text.RegularExpressions.Regex]::Matches($postActivationTemplateRaw, $literalPattern) | + ForEach-Object { $_.Value } | + Select-Object -Unique + +Write-Host " → Resolving secret literals in post-activation template" +Write-Verbose " [literal] found $($literals.Count) placeholder(s) to replace" +foreach ($literal in $literals) { + $replacementValue = New-LocalSecretValue -Placeholder $literal + $postActivationTemplateResolved = $postActivationTemplateResolved.Replace($literal, $replacementValue) + Write-Verbose " [literal] replaced placeholder '$literal'" +} + +if ([System.Text.RegularExpressions.Regex]::IsMatch($postActivationTemplateResolved, $literalPattern)) { + throw 'Failed to replace one or more *_LITERAL placeholders in post-activation Bicep template.' +} + +$resolvedPostActivationBicepFile = Join-Path ([System.IO.Path]::GetTempPath()) ("source-apim-post-activation-$([Guid]::NewGuid().ToString('N')).bicep") +Set-Content -Path $resolvedPostActivationBicepFile -Value $postActivationTemplateResolved + +$postActivationParamsFile = $null + +try { + $deploymentName = "redact-secrets-source-$(Get-Date -Format 'yyyyMMddHHmmss')" + Write-Host " → Deploying source APIM service (this can take a while)" + Write-Verbose " [deploy] deployment name: $deploymentName" + $tempCertificate = New-TemporaryPfxPayload + $azReplacements = @{ + $resolvedSubscriptionId = Protect-SubscriptionId -Value $resolvedSubscriptionId + $ResourceGroupName = Protect-ResourceGroupName -Value $ResourceGroupName + $tempCertificate.password = '*** REDACTED ***' + } + + $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 + Write-Verbose " [deploy] provisioned APIM service: $(Protect-ApimName -Value $apimServiceName)" + + Write-Host " → Waiting for APIM activation" + Wait-ApimActivation -ResourceGroupName $ResourceGroupName -ApimName $apimServiceName -TimeoutSeconds 2700 -PollIntervalSeconds 60 | Out-Null + + Write-Host " → Waiting for API to become queryable" + Wait-ApimApiQueryable -ResourceGroupName $ResourceGroupName -ApimServiceName $apimServiceName -ApiId 'src-redact-rest' -Replacements $azReplacements -TimeoutSeconds 600 -PollIntervalSeconds 20 | Out-Null + + Write-Host " → Applying post-activation policies with secret literals" + $postDeploymentName = "redact-secrets-post-activation-$(Get-Date -Format 'yyyyMMddHHmmss')" + + # Pass secure parameters via a temporary parameters file so the PFX payload and + # password never appear in the az CLI process argument list. + $postParams = [ordered]@{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + contentVersion = '1.0.0.0' + parameters = [ordered]@{ + apimName = @{ value = $apimServiceName } + tempCertificateData = @{ value = $tempCertificate.data } + tempCertificatePassword = @{ value = $tempCertificate.password } + } + } + + $postActivationParamsFile = Join-Path ([System.IO.Path]::GetTempPath()) ("source-apim-post-activation-params-$([Guid]::NewGuid().ToString('N')).json") + $postParams | ConvertTo-Json -Depth 5 | Set-Content -Path $postActivationParamsFile + + $postArgs = @( + 'deployment', 'group', 'create', + '--subscription', $resolvedSubscriptionId, + '--resource-group', $ResourceGroupName, + '--name', $postDeploymentName, + '--template-file', $resolvedPostActivationBicepFile, + '--parameters', "@$postActivationParamsFile", + '--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)'." + } + + Write-Host "✅ Phase 1 deploy complete" + $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 +} +finally { + Remove-PlaintextTempFile -Path $resolvedPostActivationBicepFile + if ($postActivationParamsFile) { + Remove-PlaintextTempFile -Path $postActivationParamsFile + } + Clear-Variable -Name postActivationTemplateResolved, postActivationTemplateRaw, tempCertificate -ErrorAction SilentlyContinue +} 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..d9c0b9cc --- /dev/null +++ b/tests/integration/redact-secrets/phases/run-phase2-extract.ps1 @@ -0,0 +1,100 @@ +# 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' +if ([string]::IsNullOrWhiteSpace($sourceSubscriptionIdValue)) { + # Fall back to the active subscription of the current az login. + Write-Verbose "No subscription id supplied; falling back to active az login subscription" + $account = Assert-AzCliLoggedIn + $sourceSubscriptionIdValue = $account.id +} + +Write-Host "📥 PHASE 2 — Extract artifacts" +Write-Verbose "Source resource group: $(Protect-ResourceGroupName -Value $SourceResourceGroup)" +Write-Verbose "Source APIM service: $(Protect-ApimName -Value $SourceApimName)" +Write-Verbose "Subscription: $(Protect-SubscriptionId -Value $sourceSubscriptionIdValue)" +Write-Verbose "Extract output directory: $ExtractOutputDir" + +if (Test-Path $ExtractOutputDir) { + Write-Host " → Cleaning previous extract output" + 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) + +Write-Host " → Running apiops extract" +$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" +} +Write-Verbose " [extract] produced $($extractedFiles.Count) file(s)" + +$resolvedExtractOutputDir = (Resolve-Path $ExtractOutputDir).Path +Write-Host "✅ Phase 2 extract complete" +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..180a0eb7 --- /dev/null +++ b/tests/integration/redact-secrets/phases/run-phase3-validate-redaction.ps1 @@ -0,0 +1,128 @@ +# 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 ***' +# Keep this split so CI log scrubbing does not rewrite the expected literal. +$expectedBearerNamedValueReference = ('Be' + 'arer {{rs-nv-secret}}') + +function Assert-PathExists { + param([string]$Path) + if (-not (Test-Path $Path)) { + throw "Expected path not found: $Path" + } + Write-Verbose " [path] exists: $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" + } + Write-Verbose " [contains] '$Expected' found in $(Split-Path -Leaf $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-Verbose " [not-contains] '$Unexpected' absent from $(Split-Path -Leaf $Path)" +} + +Write-Host "🔎 PHASE 3 — Validate secret redaction output" +Write-Verbose "Extract output directory: $ExtractOutputDir" + +$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 +} + +Write-Host " → Checking service-scope policy redaction" +Assert-Contains -Path $servicePolicyPath -Expected $redactionMarker +Assert-Contains -Path $servicePolicyPath -Expected $expectedBearerNamedValueReference +Assert-Contains -Path $servicePolicyPath -Expected '{{rs-nv-secret}}' + +# InstrumentationKeys are not secrets and must survive unredacted +# (allow-listed in src/services/secret-redactor.ts). Guards against future over-redaction. +Assert-Contains -Path $servicePolicyPath -Expected 'InstrumentationKey=AI-INSTRUMENTATION-KEY-LITERAL' + +Write-Host " → Verifying service-scope secret literals are gone" +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 +} + +Write-Host " → Checking product / api / operation / resolver policy redaction" +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' + +Write-Host " → Checking named value redaction" +$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" +} +Write-Verbose " [named-value] 'rs-nv-secret' is flagged secret and redacted" + +$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-Verbose " [named-value] 'rs-nv-plain' retained its plaintext value" + +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..9500b7ea --- /dev/null +++ b/tests/integration/redact-secrets/phases/run-phase4-teardown.ps1 @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +#requires -Version 7.0 + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$SourceResourceGroup, + + [string]$SourceSubscriptionId, + + [string]$Location = 'centralus', + + [ValidateSet('Info', 'Verbose', 'Debug')] + [string]$LogLevel = 'Verbose', + + [switch]$SkipTeardown +) + +$ErrorActionPreference = 'Stop' + +$integrationRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$maskingModule = Join-Path (Join-Path $integrationRoot 'shared/modules') 'LogMasking.psm1' +$deploymentOpsModule = Join-Path (Join-Path $integrationRoot 'shared/modules') 'DeploymentOps.psm1' +$scriptArgModule = Join-Path (Join-Path $integrationRoot 'shared/modules') 'ScriptRuntime.psm1' +Import-Module $maskingModule -Force +Import-Module $deploymentOpsModule -Force +Import-Module $scriptArgModule -Force + +Set-ScriptLogPreferences -LogLevel $LogLevel + +if ($SkipTeardown) { + Write-Host "⏭️ Teardown skipped (-SkipTeardown)" + exit 0 +} + +function Get-SubscriptionArgs { + param([string]$SubscriptionId) + if ([string]::IsNullOrWhiteSpace($SubscriptionId)) { return @() } + return @('--subscription', $SubscriptionId) +} + +Write-Host "🧹 PHASE 4 — Teardown" +Write-Verbose "Source resource group: $(Protect-ResourceGroupName -Value $SourceResourceGroup)" +Write-Verbose "Location: $Location" + +Write-Host " → Locating source APIM in resource group" +$listArgs = @('apim', 'list', '--resource-group', $SourceResourceGroup, '--query', '[0].name', '-o', 'tsv') + (Get-SubscriptionArgs -SubscriptionId $SourceSubscriptionId) +$sourceApimName = az @listArgs 2>$null +if (-not [string]::IsNullOrWhiteSpace($sourceApimName)) { + Write-Verbose " [teardown] found APIM service: $(Protect-ApimName -Value $sourceApimName)" +} +else { + Write-Verbose " [teardown] no APIM service found in resource group" +} + +if (Get-GroupExists -ResourceGroup $SourceResourceGroup -SubscriptionId $SourceSubscriptionId) { + Write-Host " → Deleting resource group" + 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)'." + } + + Write-Host " → Waiting for resource group deletion" + Wait-ForResourceGroupDeletion -ResourceGroup $SourceResourceGroup -SubscriptionId $SourceSubscriptionId +} +else { + Write-Verbose " [teardown] resource group already absent; nothing to delete" +} + +if (-not [string]::IsNullOrWhiteSpace($sourceApimName)) { + Write-Host " → Purging soft-deleted APIM service" + Wait-ForDeletedApimService -ServiceName $sourceApimName -ServiceLocation $Location -SubscriptionId $SourceSubscriptionId + 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..d9511db1 --- /dev/null +++ b/tests/integration/redact-secrets/run-redact-secrets-test.ps1 @@ -0,0 +1,136 @@ +# 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 + +Set-ScriptLogPreferences -LogLevel $LogLevel + +$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 +} + +Write-Host "🧪 Redact-secrets integration test — run id $uniqueId" + +$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' + Write-Host "▶️ Running phase 1 (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' + Write-Host "▶️ Running phase 2 (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' + Write-Host "▶️ Running phase 3 (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 { + Write-Host "▶️ Running phase 4 (teardown)" + $phase4Args = @{ + SourceResourceGroup = $SourceResourceGroup + Location = $Location + LogLevel = $LogLevel + 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..fe37f3be --- /dev/null +++ b/tests/integration/shared/modules/DeploymentOps.psm1 @@ -0,0 +1,358 @@ +# 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" +} + +<# +.SYNOPSIS +Checks whether a resource group exists. + +.PARAMETER ResourceGroup +Resource group name. + +.PARAMETER SubscriptionId +Optional subscription id for the query. + +.OUTPUTS +System.Boolean +#> +function Get-GroupExists { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourceGroup, + [string]$SubscriptionId + ) + + $azArgs = @('group', 'exists', '--name', $ResourceGroup) + if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) { + $azArgs += @('--subscription', $SubscriptionId) + } + $azArgs += @('--output', 'tsv') + + $exists = az @azArgs 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Failed to query existence for resource group '$(Protect-ResourceGroupName -Value $ResourceGroup)'." + } + + return $exists -eq 'true' +} + +<# +.SYNOPSIS +Waits for a resource group to be deleted. + +.PARAMETER ResourceGroup +Resource group name. + +.PARAMETER SubscriptionId +Optional subscription id for the query. + +.PARAMETER TimeoutMinutes +Maximum wait duration in minutes. + +.PARAMETER IntervalSeconds +Polling interval in seconds. + +.OUTPUTS +System.Boolean +#> +function Wait-ForResourceGroupDeletion { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ResourceGroup, + [string]$SubscriptionId, + [int]$TimeoutMinutes = 60, + [int]$IntervalSeconds = 30 + ) + + $waitedSeconds = 0 + $timeoutSeconds = $TimeoutMinutes * 60 + $maskedGroup = Protect-ResourceGroupName -Value $ResourceGroup + + while ($waitedSeconds -lt $timeoutSeconds) { + if (-not (Get-GroupExists -ResourceGroup $ResourceGroup -SubscriptionId $SubscriptionId)) { + return $true + } + + Write-Host " ... waiting for deletion of $maskedGroup (${waitedSeconds}s elapsed)" + Start-Sleep -Seconds $IntervalSeconds + $waitedSeconds += $IntervalSeconds + } + + throw "Timed out waiting for deletion of resource group '$maskedGroup'." +} + +<# +.SYNOPSIS +Waits for APIM soft-delete metadata to become queryable. + +.PARAMETER ServiceName +APIM service name. + +.PARAMETER ServiceLocation +APIM location. + +.PARAMETER SubscriptionId +Optional subscription id for the query. + +.PARAMETER TimeoutMinutes +Maximum wait duration in minutes. + +.PARAMETER IntervalSeconds +Polling interval in seconds. + +.OUTPUTS +System.Boolean +#> +function Wait-ForDeletedApimService { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$ServiceName, + [Parameter(Mandatory)][string]$ServiceLocation, + [string]$SubscriptionId, + [int]$TimeoutMinutes = 30, + [int]$IntervalSeconds = 15 + ) + + $waitedSeconds = 0 + $timeoutSeconds = $TimeoutMinutes * 60 + $maskedApim = Protect-ApimName -Value $ServiceName + + while ($waitedSeconds -lt $timeoutSeconds) { + $showArgs = @( + 'apim', 'deletedservice', 'show', + '--service-name', $ServiceName, + '--location', $ServiceLocation, + '--output', 'none' + ) + if (-not [string]::IsNullOrWhiteSpace($SubscriptionId)) { + $showArgs += @('--subscription', $SubscriptionId) + } + + az @showArgs 2>$null + if ($LASTEXITCODE -eq 0) { + return $true + } + + Write-Host " ... waiting for APIM soft-delete entry ($maskedApim) (${waitedSeconds}s elapsed)" + Start-Sleep -Seconds $IntervalSeconds + $waitedSeconds += $IntervalSeconds + } + + throw "Timed out waiting for APIM soft-delete entry for '$maskedApim' in location '$ServiceLocation'." +} + +Export-ModuleMember -Function ` + Write-DeploymentFailureDetails, ` + Wait-ApimActivation, ` + Wait-ApimApiQueryable, ` + Get-GroupExists, ` + Wait-ForResourceGroupDeletion, ` + Wait-ForDeletedApimService 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: --