Skip to content

Commit 967a95c

Browse files
CopilotEMaher
andauthored
Handle secret redaction in policy XML (#199)
* feat: Redact secrets from policies. Includes publish pre-flight check for redaction markers. * test: Add redact-secrets integration test * docs: extend integration prerequisite skill to redact-secrets workflow * fix: Set-ScriptLogPreferences in ScriptRuntime.psm1 to set the caller's VerbosePreference/DebugPreference via $PSCmdlet.SessionState. The previous Set-Variable -Scope 1 from a module function only affected the module's scope chain, so Write-Verbose output was silently suppressed. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Elizabeth Maher <enewman@microsoft.com>
1 parent 4bc6758 commit 967a95c

30 files changed

Lines changed: 3010 additions & 74 deletions

.github/skills/integration-test-prerequisites/SKILL.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
---
22
name: "integration-test-prerequisites"
3-
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."
3+
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."
44
domain: "ci-cd"
55
confidence: "high"
66
source: "manual + observed from integration-test OIDC and RBAC troubleshooting"
77
---
88

99
## Context
1010

11-
Use this skill when preparing or repairing prerequisites for `.github/workflows/integration-test.yml`.
11+
Use this skill when preparing or repairing prerequisites for:
1212

13-
This workflow expects:
13+
- `.github/workflows/integration-test.yml`
14+
- `.github/workflows/integration-redact-secrets.yml`
15+
16+
These workflows expect:
1417
- OIDC login through `azure/login@v2`
15-
- GitHub environment `integration-test`
18+
- GitHub environment `integration-test` (shared by both workflows)
1619
- Azure identity with enough permissions to deploy resources and create role assignments in test resource groups
1720

1821
Preferred identity model: user-assigned managed identity (UAMI).
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
name: "Test: Extract Secret Redaction"
5+
6+
on:
7+
workflow_dispatch:
8+
inputs:
9+
sku:
10+
description: 'APIM SKU'
11+
required: true
12+
type: choice
13+
options:
14+
- Standard
15+
- StandardV2
16+
- Premium
17+
- PremiumV2
18+
default: StandardV2
19+
location:
20+
description: 'Azure region'
21+
required: false
22+
type: string
23+
default: 'centralus'
24+
log_level:
25+
description: 'PowerShell log level (Info, Verbose, Debug)'
26+
required: false
27+
type: string
28+
default: Verbose
29+
30+
workflow_call:
31+
inputs:
32+
sku:
33+
description: 'APIM SKU'
34+
required: false
35+
type: string
36+
default: StandardV2
37+
location:
38+
description: 'Azure region'
39+
required: false
40+
type: string
41+
default: 'centralus'
42+
log_level:
43+
description: 'PowerShell log level (Info, Verbose, Debug)'
44+
required: false
45+
type: string
46+
default: Verbose
47+
secrets:
48+
AZURE_CLIENT_ID:
49+
required: true
50+
AZURE_TENANT_ID:
51+
required: true
52+
AZURE_SUBSCRIPTION_ID:
53+
required: true
54+
APIM_PUBLISHER_EMAIL:
55+
required: true
56+
APIM_SKU:
57+
required: false
58+
59+
permissions:
60+
id-token: write
61+
contents: read
62+
63+
concurrency:
64+
group: integration-redact-secrets
65+
cancel-in-progress: false
66+
67+
jobs:
68+
redact-secrets-test:
69+
name: Extract Secret Redaction
70+
if: github.ref == 'refs/heads/main'
71+
runs-on: ubuntu-latest
72+
timeout-minutes: 120
73+
environment: integration-test
74+
75+
steps:
76+
- uses: actions/checkout@v6
77+
78+
- uses: actions/setup-node@v5
79+
with:
80+
node-version: '22'
81+
cache: 'npm'
82+
83+
- run: npm ci && npm run build
84+
85+
- name: Resolve Workflow Settings
86+
id: settings
87+
shell: pwsh
88+
run: |
89+
$logLevel = '${{ inputs.log_level }}'
90+
if ([string]::IsNullOrWhiteSpace($logLevel)) { $logLevel = 'Verbose' }
91+
if ($logLevel -notin @('Info', 'Verbose', 'Debug')) {
92+
throw "Invalid log_level '$logLevel'. Allowed values: Info, Verbose, Debug."
93+
}
94+
95+
$skuName = '${{ secrets.APIM_SKU }}'
96+
if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = '${{ inputs.sku }}' }
97+
if ([string]::IsNullOrWhiteSpace($skuName)) { $skuName = 'StandardV2' }
98+
99+
"logLevel=$logLevel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
100+
"skuName=$skuName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
101+
102+
- name: Azure Login
103+
uses: azure/login@v3
104+
with:
105+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
106+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
107+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
108+
109+
- name: Run redaction integration test
110+
shell: pwsh
111+
env:
112+
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
113+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
114+
run: |
115+
./tests/integration/redact-secrets/run-redact-secrets-test.ps1 `
116+
-SourceSubscriptionId '${{ secrets.AZURE_SUBSCRIPTION_ID }}' `
117+
-PublisherEmail '${{ secrets.APIM_PUBLISHER_EMAIL }}' `
118+
-SkuName '${{ steps.settings.outputs.skuName }}' `
119+
-Location '${{ inputs.location }}' `
120+
-LogLevel '${{ steps.settings.outputs.logLevel }}'

.gitignore

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ Desktop.ini
3939
.local-extract*/
4040

4141
# Files for integration tests
42-
tests/integration/all-resource-types/**/logs/**
43-
tests/integration/all-resource-types/**/extracted-artifacts*/**
44-
tests/integration/all-resource-types/bicep/target-apim.json
45-
tests/integration/all-resource-types/bicep/source-apim.json
46-
tests/integration/all-resource-types/bicep/source-apim-post-activation.bicep
42+
tests/integration/**/logs
43+
tests/integration/**/extracted-artifacts*
44+
tests/integration/**/bicep/target-apim.json
45+
tests/integration/**/bicep/source-apim.json
46+
tests/integration/**/bicep/source-apim-post-activation.bicep
4747

4848
# Environment variables
4949
.env

.squad/agents/apimexpert/history.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,17 @@ When comparing a *link-imported* API (e.g. Petstore via `swagger-link`/`openapi-
116116

117117
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.
118118

119+
### 2026-07-01: Overriding a policy to clear a redacted secret — possible but rarely the right fix
120+
121+
**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?
122+
123+
**Answer: yes, technically — but it's almost never the intended path.**
124+
125+
- All five policy types are wired into the override system (`src/services/override-merger.ts`): `ServicePolicy → policies`, `PolicyFragment → policyFragments` (direct); `ApiPolicy → apis.<api>.policies`, `ProductPolicy → products.<product>.policies` (child); `ApiOperationPolicy → apis.<api>.operations.<op>.policies` (grandchild).
126+
- 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.
127+
- 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.
128+
129+
**Why it's the wrong tool for redacted secrets:**
130+
- The marker is inserted **inline** inside the XML (e.g. inside a `set-header` `<value>`), 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.
131+
- 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.
132+

src/services/api-extractor.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.j
1414
import { FilterConfig } from '../models/config.js';
1515
import { shouldIncludeResource } from './filter-service.js';
1616
import { extractResourceType, ExtractedResource } from './resource-extractor.js';
17+
import { redactAndWarnPolicySecrets } from './secret-redactor.js';
1718
import { logger } from '../lib/logger.js';
1819
import { buildResourceLabel } from '../lib/resource-uri.js';
1920
import { getNamePart } from '../lib/resource-path.js';
@@ -363,14 +364,15 @@ async function extractApiPolicy(
363364
const policyContent = properties?.value as string | undefined;
364365

365366
if (policyContent) {
367+
const redactedContent = redactAndWarnPolicySecrets(policyDescriptor, policyContent);
366368
await store.writeContent(
367369
outputDir,
368370
policyDescriptor,
369-
policyContent,
371+
redactedContent,
370372
'policy'
371373
);
372374
logger.debug(`Extracted ${buildResourceLabel(policyDescriptor)}`);
373-
return policyContent;
375+
return redactedContent;
374376
}
375377

376378
return undefined;
@@ -420,13 +422,14 @@ async function extractApiOperations(
420422
const policyContent = properties?.value as string | undefined;
421423

422424
if (policyContent) {
423-
await store.writeContent(outputDir, opPolicyDescriptor, policyContent, 'policy');
425+
const redactedContent = redactAndWarnPolicySecrets(opPolicyDescriptor, policyContent);
426+
await store.writeContent(outputDir, opPolicyDescriptor, redactedContent, 'policy');
424427
operationPolicies.push({
425428
descriptor: opPolicyDescriptor,
426429
json: policyJson,
427430
status: 'success',
428431
});
429-
policies.push(policyContent);
432+
policies.push(redactedContent);
430433
logger.debug(`Extracted ${buildResourceLabel(opPolicyDescriptor)}`);
431434
}
432435
}
@@ -531,13 +534,14 @@ async function extractGraphQLResolvers(
531534
const policyContent = props?.value as string | undefined;
532535

533536
if (policyContent) {
534-
await store.writeContent(outputDir, resolverPolicyDescriptor, policyContent, 'policy');
537+
const redactedContent = redactAndWarnPolicySecrets(resolverPolicyDescriptor, policyContent);
538+
await store.writeContent(outputDir, resolverPolicyDescriptor, redactedContent, 'policy');
535539
resolverPolicies.push({
536540
descriptor: resolverPolicyDescriptor,
537541
json: policyJson,
538542
status: 'success',
539543
});
540-
policies.push(policyContent);
544+
policies.push(redactedContent);
541545
logger.debug(`Extracted ${buildResourceLabel(resolverPolicyDescriptor)}`);
542546
}
543547
}

src/services/extract-service.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { extractWorkspaces, WorkspaceExtractionResult } from './workspace-extrac
2929
import {
3030
findTransitiveDependencies,
3131
} from './transitive-resolver.js';
32+
import { redactAndWarnPolicySecrets } from './secret-redactor.js';
3233
import { logger } from '../lib/logger.js';
3334
import { buildResourceLabel } from '../lib/resource-uri.js';
3435
import { EXIT_SUCCESS, EXIT_PARTIAL, EXIT_FATAL } from '../lib/exit-codes.js';
@@ -401,10 +402,11 @@ async function extractServicePolicy(
401402
const policyContent = properties?.value as string | undefined;
402403

403404
if (policyContent) {
404-
await store.writeContent(outputDir, descriptor, policyContent, 'policy');
405+
const redactedContent = redactAndWarnPolicySecrets(descriptor, policyContent);
406+
await store.writeContent(outputDir, descriptor, redactedContent, 'policy');
405407
result.totalExtracted++;
406408
result.extractedDescriptors.push(descriptor);
407-
result.collectedPolicies.set('service-policy', policyContent);
409+
result.collectedPolicies.set('service-policy', redactedContent);
408410
logger.info('Extracted service-level policy');
409411
}
410412
}

src/services/product-extractor.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ApimServiceContext, AssociationEntry, ResourceDescriptor } from '../mod
1111
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
1212
import { FilterConfig } from '../models/config.js';
1313
import { extractResourceName } from './resource-extractor.js';
14+
import { redactAndWarnPolicySecrets } from './secret-redactor.js';
1415
import { logger } from '../lib/logger.js';
1516
import { getNamePart } from '../lib/resource-path.js';
1617
import { isWorkspaceScope, extractNameFromLink, extractLinkTarget } from '../lib/workspace-link.js';
@@ -215,9 +216,10 @@ async function extractProductPolicy(
215216
const policyContent = properties?.value as string | undefined;
216217

217218
if (policyContent) {
218-
await store.writeContent(outputDir, policyDescriptor, policyContent, 'policy');
219+
const redactedContent = redactAndWarnPolicySecrets(policyDescriptor, policyContent);
220+
await store.writeContent(outputDir, policyDescriptor, redactedContent, 'policy');
219221
logger.debug(`Extracted policy for product "${getNamePart(productDescriptor.nameParts, 0)}"`);
220-
return policyContent;
222+
return redactedContent;
221223
}
222224

223225
return undefined;

src/services/publish-service.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import { publishProduct } from './product-publisher.js';
2626
import { generateDryRunReport, DryRunReport } from './dry-run-reporter.js';
2727
import { computeDeleteActions } from './delete-unmatched-service.js';
2828
import { computeGitDiff } from './git-diff-service.js';
29+
import { scanForRedactionMarkers } from './secret-redaction-guard.js';
30+
import { REDACTION_MARKER } from './secret-redactor.js';
2931

3032
/**
3133
* The APIM Backend properties.type value that identifies a pool backend.
@@ -79,6 +81,44 @@ export async function runPublish(
7981
`Publishing ${targetDescriptors.length} resources (dry-run: ${config.dryRun})`
8082
);
8183

84+
// Step 1b: Redaction pre-flight gate.
85+
// Scan every artifact that would be published for leftover redaction markers
86+
// ('*** REDACTED ***'). A single leftover marker aborts the ENTIRE publish
87+
// before any PUT is issued — and also fails dry-run — so a service can never
88+
// be left partially published with placeholder secrets.
89+
const redactionFindings = await scanForRedactionMarkers(
90+
store,
91+
config,
92+
targetDescriptors
93+
);
94+
if (redactionFindings.length > 0) {
95+
logger.error(
96+
`Publish aborted: ${redactionFindings.length} artifact(s) still contain the redaction marker '${REDACTION_MARKER}'. ` +
97+
'Replace inline secrets with named values or KeyVault references before publishing: ' +
98+
'https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-properties'
99+
);
100+
const actions: PublishActionResult[] = redactionFindings.map((finding) => {
101+
logger.error(` - ${finding.label} (${finding.location})`);
102+
return {
103+
descriptor: finding.descriptor,
104+
action: 'noop',
105+
status: 'failed',
106+
error: new Error(
107+
`${finding.label} contains '${REDACTION_MARKER}' in ${finding.location}`
108+
),
109+
};
110+
});
111+
112+
return {
113+
totalPuts: 0,
114+
totalDeletes: 0,
115+
totalErrors: actions.length,
116+
totalSkipped: 0,
117+
exitCode: EXIT_FATAL,
118+
actions,
119+
};
120+
}
121+
82122
// Step 2: Handle dry-run mode
83123
if (config.dryRun) {
84124
const dryRunReport = await generateDryRunReport(

src/services/resource-publisher.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { logger } from '../lib/logger.js';
2121
import { REDACTION_MARKER } from './secret-redactor.js';
2222
import { isLinkAlreadyExistsError } from '../clients/apim-client.js';
2323
import type { OverrideConfig } from '../models/config.js';
24+
import { buildResourceLabel } from '../lib/resource-uri.js';
2425

2526
export interface ResourcePublishResult {
2627
descriptor: ResourceDescriptor;
@@ -32,7 +33,7 @@ export interface ResourcePublishResult {
3233
/**
3334
* Policy resource types that have external XML content
3435
*/
35-
const POLICY_TYPES = new Set<ResourceType>([
36+
export const POLICY_TYPES = new Set<ResourceType>([
3637
ResourceType.ServicePolicy,
3738
ResourceType.ProductPolicy,
3839
ResourceType.ApiPolicy,
@@ -416,6 +417,9 @@ async function publishPolicy(
416417
};
417418
}
418419

420+
// Fail-safe guard: extracted policies don't currently carry separate metadata
421+
// indicating prior redaction, so marker detection is a deliberate content
422+
// check to block publishing placeholder secrets.
419423
const payload: Record<string, unknown> = {
420424
properties: {
421425
value: policyContent.content,
@@ -426,6 +430,19 @@ async function publishPolicy(
426430
// Apply overrides (e.g., format: xml) before PUT — matches Toolkit behavior
427431
const mergedPayload = applyOverrides(descriptor, payload, config.overrides);
428432

433+
// Marker check runs AFTER overrides: an override may legitimately replace the
434+
// policy value with clean content, so only the merged (about-to-be-published)
435+
// value is authoritative for redaction detection.
436+
const mergedProps = mergedPayload.properties as Record<string, unknown> | undefined;
437+
const mergedValue = mergedProps?.value;
438+
if (typeof mergedValue === 'string' && mergedValue.includes(REDACTION_MARKER)) {
439+
throw new Error(
440+
`Cannot publish ${buildResourceLabel(descriptor)}: policy contains '${REDACTION_MARKER}'. ` +
441+
'Replace inline secrets with named values before publish: ' +
442+
'https://learn.microsoft.com/en-us/azure/api-management/api-management-howto-properties'
443+
);
444+
}
445+
429446
await client.putResource(context, descriptor, mergedPayload);
430447

431448
return {

0 commit comments

Comments
 (0)