1#!/usr/bin/env pwsh2# Scaffold -> Deploy conformance gate.3# Checks generated IaC against the plan for the semantic defects `az bicep build`4# cannot catch (valid Bicep, wrong values) — the class that causes deploy healing.5#6# Usage: scaffold-conformance.ps1 -SessionPath <.copilot-azure/sessions/{id}> -InfraPath <infra>7# Output: JSON { passed, failures:[{id,detail,file}] } to stdout.8# Exit: 0 = pass, 1 = one or more BLOCK failures.9[CmdletBinding()]10param(11 [Parameter(Mandatory)] [string]$SessionPath,
50 Add-Fail 'DIAG-GATED' 'diagnostic-settings module wired without an enableDiagnostics gate — gate it (module ... = if (enableDiagnostics), default false) or remove it; unconditional wiring blocks the first deploy' 'infra/main.bicep'
51}
52
53# 2. NO-PLAINTEXT-SECRET — secrets are @secure() params passed at deploy, never literals.
54if ($paramsRaw) {
55 try {
56 $pj = $paramsRaw | ConvertFrom-Json
57 foreach ($k in $pj.parameters.PSObject.Properties.Name) {
58 if ($k -match '(?i)password|secret|connstring|connection') {
59 $val = $pj.parameters.$k.value
60 if ($null -ne $val -and "$val".Trim().Length -gt 0) {
61 Add-Fail 'NO-PLAINTEXT-SECRET' "parameter '$k' has a literal value — must be @secure(), passed at deploy" 'infra/main.parameters.json'
62 }
63 }
64 }
65 } catch { }
66}
67
68# 2b. NO-BICEP-LITERAL-SECRET — secret values must be @secure() params, never quoted literals in Bicep.
69# Property name must end in 'password'/'connectionString' right before ':' (so 'secretName' etc. never match).
70if ($iac) {
71 foreach ($m in [regex]::Matches($iac, "(?im)\b([A-Za-z]*(?:password|connectionstring))\s*:\s*'([^']+)'")) {
72 Add-Fail 'NO-BICEP-LITERAL-SECRET' "property '$($m.Groups[1].Value)' assigned a literal secret in Bicep — use an @secure() param passed at deploy" 'infra/'
73 }
74 if ($iac -match "(?im):\s*'[^']*(?:Password=|AccountKey=|SharedAccessKey=|://[^:@/\s]+:[^:@/\s]+@)[^']*'") {
75 Add-Fail 'NO-BICEP-LITERAL-SECRET' 'quoted literal contains an embedded credential (Password=/AccountKey=/user:pass@) in Bicep — use an @secure() param' 'infra/'
76 }
77}
78
79# 3. SERVICES-COMPLETE — every planned service maps to a resource type present in the IaC.
129 # 6. DB-NAME-PRESENT — the app's named DB must exist in IaC (from prepare-plan.appDbName).
130 if ($plan.appDbName -and ($iac -notmatch 'flexibleServers/databases')) {
131 Add-Fail 'DB-NAME-PRESENT' "app DB '$($plan.appDbName)' declared but no flexibleServers/databases resource" 'infra/modules'
132 }
133
134 # 10. MYSQL-NO-NETWORK-BLOCK (MySQL) — the public-access flow must OMIT the network block.
135 # An empty delegatedSubnetResourceId/privateDnsZoneResourceId is rejected by ARM (LinkedInvalidPropertyId).
136 if ($hasMysql -and ($iac -match 'delegatedSubnetResourceId|privateDnsZoneResourceId')) {
137 Add-Fail 'MYSQL-NO-NETWORK-BLOCK' 'MySQL module includes a network block (delegatedSubnetResourceId) — omit it for public access; an empty value is rejected by ARM' 'infra/modules'
138 }
139
140 # 11. DB-LOGIN-NOT-RESERVED — administratorLogin must not be an Azure-reserved name.
141 if ($iac -match "administratorLogin:\s*'(root|admin|administrator|guest|public|sa|azure_superuser|azure_pg_admin)'") {
142 Add-Fail 'DB-LOGIN-NOT-RESERVED' "administratorLogin uses a reserved name ('$($Matches[1])') — derive a safe login (e.g. '{project}admin'), never a compose-sourced reserved name" 'infra/modules'
143 }
144}
145
146# --- Key Vault checks ---
147# Gate on the IaC resource (matches the .sh twin and this script's own CA/ACR gating) — NOT the plan
148# service name, which misses KV when the plan names the service anything other than 'Key Vault'.
170 Add-Fail 'CAE-APPLOGS' "Container Apps Environment uses a bare logAnalyticsConfiguration — nest it under appLogsConfiguration.destination='log-analytics' (else ManagedEnvironmentInvalidSchema at deploy)" 'infra/modules'
173 if ($hasCA -and ($iac -match 'revisionSuffix\s*:')) {
174 Add-Fail 'CA-NO-REVISION-SUFFIX' 'Container App sets revisionSuffix — omit it (ARM auto-generates); a hardcoded value fails Phase 2 redeploy' 'infra/modules'
175 }
176 # 15. CA-IMAGE-PARAM — two-phase deploy needs `param containerImage` in main.bicep, else the Phase 2 `--parameters containerImage=` override is silently ignored and the placeholder image persists.
177 if ($hasCA -and $hasAcr -and ($mainBicep -notmatch 'param\s+containerImage')) {
178 Add-Fail 'CA-IMAGE-PARAM' 'main.bicep lacks `param containerImage` — the Phase 2 image override is silently ignored and the placeholder persists (MANIFEST_UNKNOWN)' 'infra/main.bicep'
179 }
180 # 16. ACRPULL-GUID — near-miss detector: the fixed AcrPull prefix present without the canonical full GUID = wrong last segment (hallucinated) → RoleDefinitionDoesNotExist. Cannot false-positive.
181 if (($iac -match '7f951dda-4ed3-4680-a7ca-') -and ($iac -notmatch '7f951dda-4ed3-4680-a7ca-43fe172d538d')) {
182 Add-Fail 'ACRPULL-GUID' 'AcrPull role GUID is wrong — canonical value is 7f951dda-4ed3-4680-a7ca-43fe172d538d (RoleDefinitionDoesNotExist otherwise)' 'infra/modules/role-assignments.bicep'
183 }
184 # 17. ACR-NO-PREMIUM-POLICY — retention/trust/quarantine are Premium-only; on Basic/Standard ACR they fail SkuNotSupported. FP-safe: skipped if any 'Premium' SKU appears in the IaC.
186 Add-Fail 'ACR-NO-PREMIUM-POLICY' 'ACR has a Premium-only policy (retention/trust/quarantine) but is not Premium — omit these for Basic/Standard (SkuNotSupported at deploy)' 'infra/modules'
187 }
188}
189
190# 9. WARN-FIXED — prereq warnings flagged fixPhase:"scaffold" must land in the generated IaC.
191# Only warnings with a provable signal are enforced; unverifiable ones are skipped (no false BLOCK).
192# Extend $warnSignal as new provable scaffold-phase warning IDs are added.