34if printf '%s' "$main_bicep" | grep -qE 'appOnboard(Skill|SessionId)|createdAt:|deployedBy:'; then
35 add_fail "TAGS-NO-CAMEL" "camelCase tag keys found — use hyphenated ('app-onboard-skill' ...)" "infra/main.bicep"
36fi
37
38# 12. DIAG-GATED — we don't scaffold diagnostic-settings; if present it must be gated behind enableDiagnostics (default false) or removed. Unconditional wiring blocks the first deploy.
39# Match an actual diagnostic module/resource declaration, not a prose comment mentioning 'diagnostic'.
41 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"
42fi
43
44# 2. NO-PLAINTEXT-SECRET (jq-free: awk over the params file so it runs on hosts without jq)
45if [ -n "$params_raw" ]; then
46 bad_keys="$(printf '%s' "$params_raw" | awk '
47 { line=tolower($0) }
48 line ~ /"[a-z0-9_]*(password|secret|connstring|connection)[a-z0-9_]*"[ \t]*:/ {
49 n=split($0, parts, "\"")
50 for (i=1;i<=n;i++) { if (tolower(parts[i]) ~ /(password|secret|connstring|connection)/) { pend=parts[i]; break } }
58 add_fail "NO-PLAINTEXT-SECRET" "parameter '$k' has a literal value — must be @secure(), passed at deploy" "infra/main.parameters.json"
59 done <<< "$bad_keys"
60 fi
61fi
62
63# 2b. NO-BICEP-LITERAL-SECRET (pure-text) — secret values must be @secure() params, never quoted literals.
64# Property name must end in 'password'/'connectionString' right before ':' (so 'secretName' etc. never match).
65if [ -n "$iac" ]; then
66 if printf '%s' "$iac" | grep -qiE "[A-Za-z]*(password|connectionstring)[[:space:]]*:[[:space:]]*'[^']+'"; then
67 k="$(printf '%s' "$iac" | grep -oiE "[A-Za-z]*(password|connectionstring)[[:space:]]*:[[:space:]]*'[^']+'" | head -n1 | sed -E "s/[[:space:]]*:.*//")"
68 add_fail "NO-BICEP-LITERAL-SECRET" "property '$k' assigned a literal secret in Bicep — use an @secure() param passed at deploy" "infra/"
69 fi
70 if printf '%s' "$iac" | grep -qE ":[[:space:]]*'[^']*(Password=|AccountKey=|SharedAccessKey=|://[^:@/ ]+:[^:@/ ]+@)[^']*'"; then
71 add_fail "NO-BICEP-LITERAL-SECRET" "quoted literal contains an embedded credential (Password=/AccountKey=/user:pass@) in Bicep — use an @secure() param" "infra/"
72 fi
73fi
74
75# service-name -> resource-type map (longest keys first so 'container apps environment' wins)
148 # 6. DB-NAME-PRESENT (needs plan.appDbName from jq)
149 if [ -n "$app_db_name" ] && ! iac_has 'flexibleServers/databases'; then
150 add_fail "DB-NAME-PRESENT" "app DB '$app_db_name' declared but no flexibleServers/databases resource" "infra/modules"
151 fi
152 # 10. MYSQL-NO-NETWORK-BLOCK (MySQL, pure-text) — public flow must omit the network block;
153 # an empty delegatedSubnetResourceId is rejected by ARM (LinkedInvalidPropertyId).
154 if [ "$has_mysql" = 1 ] && iac_has 'delegatedSubnetResourceId|privateDnsZoneResourceId'; then
155 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"
156 fi
157 # 11. DB-LOGIN-NOT-RESERVED (pure-text) — administratorLogin must not be an Azure-reserved name.
158 if iac_has "administratorLogin:[[:space:]]*'(root|admin|administrator|guest|public|sa|azure_superuser|azure_pg_admin)'"; then
160 add_fail "DB-LOGIN-NOT-RESERVED" "administratorLogin uses a reserved name ('${resv}') — derive a safe login (e.g. '{project}admin'), never a compose-sourced reserved name" "infra/modules"
161 fi
162fi
163
164# --- Key Vault checks (pure-text) ---
165if iac_has 'Microsoft\.KeyVault/vaults'; then
166 # 7. KV-NO-PURGE
167 if iac_has 'enablePurgeProtection'; then
168 add_fail "KV-NO-PURGE" "enablePurgeProtection present — omit it (policy may reject false)" "infra/modules"
183 # 13. CAE-APPLOGS — managedEnvironments log config MUST nest under appLogsConfiguration; a bare
184 # top-level logAnalyticsConfiguration fails deploy with ManagedEnvironmentInvalidSchema.
185 if [ "$has_cae" = 1 ] && iac_has 'logAnalyticsConfiguration' && ! iac_has 'appLogsConfiguration'; then
186 add_fail "CAE-APPLOGS" "Container Apps Environment uses a bare logAnalyticsConfiguration — nest it under appLogsConfiguration.destination='log-analytics' (else ManagedEnvironmentInvalidSchema at deploy)" "infra/modules"
189 if [ "$has_ca" = 1 ] && iac_has 'revisionSuffix[[:space:]]*:'; then
190 add_fail "CA-NO-REVISION-SUFFIX" "Container App sets revisionSuffix — omit it (ARM auto-generates); a hardcoded value fails Phase 2 redeploy" "infra/modules"
191 fi
192 # 15. CA-IMAGE-PARAM — two-phase deploy needs 'param containerImage' in main.bicep, else the Phase 2 override is silently ignored and the placeholder image persists.
194 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"
195 fi
196 # 16. ACRPULL-GUID — near-miss detector: fixed AcrPull prefix present without the canonical full GUID = hallucinated last segment → RoleDefinitionDoesNotExist. Cannot false-positive.
197 if iac_has '7f951dda-4ed3-4680-a7ca-' && ! iac_has '7f951dda-4ed3-4680-a7ca-43fe172d538d'; then
198 add_fail "ACRPULL-GUID" "AcrPull role GUID is wrong — canonical value is 7f951dda-4ed3-4680-a7ca-43fe172d538d (RoleDefinitionDoesNotExist otherwise)" "infra/modules/role-assignments.bicep"
199 fi
200 # 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.
201 if [ "$has_acr" = 1 ] && iac_has '(retentionPolicy|trustPolicy|quarantinePolicy)[[:space:]]*:' && ! iac_has "'Premium'"; then
202 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"