Skip to content

Commit bbe3e24

Browse files
authored
FEAT: Inline Container App secret + KV lockdown for GUI deployments (#1721)
1 parent a9c93dc commit bbe3e24

9 files changed

Lines changed: 598 additions & 185 deletions

File tree

docker/start.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ fi
3737
echo "Checking PyRIT installation..."
3838
python -c "import pyrit; print(f'Running PyRIT version: {pyrit.__version__}')"
3939

40-
# Write .env file from PYRIT_ENV_CONTENTS (injected from Key Vault secret)
40+
# Write .env file from PYRIT_ENV_CONTENTS (injected as the Container App's
41+
# inline `env-file` secret; previously a Key Vault secretRef, but ACA isn't on
42+
# Key Vault's "trusted services" list so SFI-locked-down KVs can't be read at
43+
# runtime — see infra/main.bicep for details).
4144
if [ -n "$PYRIT_ENV_CONTENTS" ]; then
4245
mkdir -p ~/.pyrit
4346
echo "$PYRIT_ENV_CONTENTS" > ~/.pyrit/.env

gui-deploy.yml

Lines changed: 185 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@
1010
# - copyrit-gui-common (azureServiceConnection, acrName, acrLoginServer, imageName)
1111
# - copyrit-gui-test (resourceGroup, appName, entraTenantId, entraClientId,
1212
# allowedGroupObjectIds, sqlServerFqdn, sqlDatabaseName,
13-
# keyVaultResourceId, acrResourceId, enablePrivateEndpoint, enableOtel)
13+
# keyVaultResourceId, acrResourceId, enablePrivateEndpoint, enableOtel,
14+
# envFileContents [SECURE])
1415
# - copyrit-gui-prod (same keys as test, with production values)
16+
#
17+
# The `envFileContents` variable must be marked SECURE in the ADO library.
18+
# It contains the entire .env file (endpoints, keys, etc.) and is passed to
19+
# the Bicep template as the @secure() `envFileContents` parameter via a
20+
# parameters JSON file written to disk in the runner. Writing to a file
21+
# (instead of `--parameters envFileContents=$(envFileContents)`) avoids
22+
# exposing the value in the runner's process list / `ps` output.
1523

1624
trigger:
1725
branches:
@@ -118,21 +126,97 @@ stages:
118126
inlineScript: |
119127
set -euo pipefail
120128
129+
# Write deployment parameters to a temp file so the
130+
# @secure() envFileContents value never appears on the
131+
# az CLI command line (visible in process list).
132+
PARAMS_FILE="$(mktemp --suffix=.parameters.json)"
133+
trap 'rm -f "$PARAMS_FILE"' EXIT
134+
135+
python3 - <<'PY' > "$PARAMS_FILE"
136+
import json, os, sys
137+
138+
139+
def parse_bool(name: str) -> bool:
140+
"""Strict bool parse: fail loudly if the ADO variable was unresolved.
141+
142+
ADO leaves unresolved macro variables as the literal string
143+
'$(varname)'. Without this guard, that string would silently
144+
coerce to False (since 'lower() == "true"' is False), which can
145+
deploy with the wrong network posture (e.g., public access on
146+
when private endpoint was intended). Fail loudly instead.
147+
"""
148+
val = os.environ.get(name, "")
149+
if val.lower() in ("true", "false"):
150+
return val.lower() == "true"
151+
sys.exit(
152+
f"ERROR: Pipeline variable {name} must be 'true' or 'false', "
153+
f"got: {val!r}. Check the ADO variable group (likely the "
154+
f"variable is missing or misspelled — ADO leaves unresolved "
155+
f"macros as the literal string '$(name)')."
156+
)
157+
158+
159+
def parse_secret_str(name: str) -> str:
160+
"""Fail loudly if a SECURE pipeline variable was unresolved.
161+
162+
Same root cause as parse_bool: ADO leaves unresolved macros as
163+
'$(varname)'. For securestring parameters (envFileContents), Azure
164+
accepts ANY string, so an unresolved macro silently deploys garbage
165+
that the container can't parse. Detect the pattern and abort.
166+
"""
167+
val = os.environ.get(name, "")
168+
# Match exactly '$(identifier)' — ADO macro syntax for unresolved variables.
169+
if val.startswith("$(") and val.endswith(")") and "(" not in val[2:-1]:
170+
sys.exit(
171+
f"ERROR: Pipeline variable {name} appears unresolved (got "
172+
f"'{val}'). The ADO library variable is likely missing or "
173+
f"misspelled (ADO leaves unresolved macros as the literal "
174+
f"string '$(name)'). For envFileContents, also confirm the "
175+
f"variable is marked SECURE in the ADO library."
176+
)
177+
if not val:
178+
sys.exit(f"ERROR: Pipeline variable {name} is empty.")
179+
return val
180+
181+
182+
doc = {
183+
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
184+
"contentVersion": "1.0.0.0",
185+
"parameters": {
186+
"appName": {"value": os.environ["APP_NAME"]},
187+
"containerImage": {"value": os.environ["CONTAINER_IMAGE"]},
188+
"entraTenantId": {"value": os.environ["ENTRA_TENANT_ID"]},
189+
"entraClientId": {"value": os.environ["ENTRA_CLIENT_ID"]},
190+
"allowedGroupObjectIds": {"value": os.environ["ALLOWED_GROUP_OBJECT_IDS"]},
191+
"sqlServerFqdn": {"value": os.environ["SQL_SERVER_FQDN"]},
192+
"sqlDatabaseName": {"value": os.environ["SQL_DATABASE_NAME"]},
193+
"keyVaultResourceId": {"value": os.environ["KEY_VAULT_RESOURCE_ID"]},
194+
"acrResourceId": {"value": os.environ["ACR_RESOURCE_ID"]},
195+
"enablePrivateEndpoint": {"value": parse_bool("ENABLE_PRIVATE_ENDPOINT")},
196+
"enableOtel": {"value": parse_bool("ENABLE_OTEL")},
197+
"envFileContents": {"value": parse_secret_str("ENV_FILE_CONTENTS")},
198+
},
199+
}
200+
print(json.dumps(doc))
201+
PY
202+
121203
az deployment group create \
122204
--resource-group $(resourceGroup) \
123205
--template-file $(Build.SourcesDirectory)/infra/main.bicep \
124-
--parameters appName=$(appName) \
125-
--parameters containerImage=$(acrLoginServer)/$(imageName):$(Build.SourceVersion) \
126-
--parameters entraTenantId=$(entraTenantId) \
127-
--parameters entraClientId=$(entraClientId) \
128-
--parameters allowedGroupObjectIds="$(allowedGroupObjectIds)" \
129-
--parameters sqlServerFqdn=$(sqlServerFqdn) \
130-
--parameters sqlDatabaseName=$(sqlDatabaseName) \
131-
--parameters keyVaultResourceId=$(keyVaultResourceId) \
132-
--parameters acrResourceId=$(acrResourceId) \
133-
--parameters enablePrivateEndpoint=$(enablePrivateEndpoint) \
134-
--parameters enableOtel=$(enableOtel) \
135-
--parameters envSecretName=$(envSecretName)
206+
--parameters @"$PARAMS_FILE"
207+
env:
208+
APP_NAME: $(appName)
209+
CONTAINER_IMAGE: $(acrLoginServer)/$(imageName):$(Build.SourceVersion)
210+
ENTRA_TENANT_ID: $(entraTenantId)
211+
ENTRA_CLIENT_ID: $(entraClientId)
212+
ALLOWED_GROUP_OBJECT_IDS: $(allowedGroupObjectIds)
213+
SQL_SERVER_FQDN: $(sqlServerFqdn)
214+
SQL_DATABASE_NAME: $(sqlDatabaseName)
215+
KEY_VAULT_RESOURCE_ID: $(keyVaultResourceId)
216+
ACR_RESOURCE_ID: $(acrResourceId)
217+
ENABLE_PRIVATE_ENDPOINT: $(enablePrivateEndpoint)
218+
ENABLE_OTEL: $(enableOtel)
219+
ENV_FILE_CONTENTS: $(envFileContents)
136220

137221
- task: AzureCLI@2
138222
displayName: 'Health check'
@@ -189,21 +273,97 @@ stages:
189273
inlineScript: |
190274
set -euo pipefail
191275
276+
# Write deployment parameters to a temp file so the
277+
# @secure() envFileContents value never appears on the
278+
# az CLI command line (visible in process list).
279+
PARAMS_FILE="$(mktemp --suffix=.parameters.json)"
280+
trap 'rm -f "$PARAMS_FILE"' EXIT
281+
282+
python3 - <<'PY' > "$PARAMS_FILE"
283+
import json, os, sys
284+
285+
286+
def parse_bool(name: str) -> bool:
287+
"""Strict bool parse: fail loudly if the ADO variable was unresolved.
288+
289+
ADO leaves unresolved macro variables as the literal string
290+
'$(varname)'. Without this guard, that string would silently
291+
coerce to False (since 'lower() == "true"' is False), which can
292+
deploy with the wrong network posture (e.g., public access on
293+
when private endpoint was intended). Fail loudly instead.
294+
"""
295+
val = os.environ.get(name, "")
296+
if val.lower() in ("true", "false"):
297+
return val.lower() == "true"
298+
sys.exit(
299+
f"ERROR: Pipeline variable {name} must be 'true' or 'false', "
300+
f"got: {val!r}. Check the ADO variable group (likely the "
301+
f"variable is missing or misspelled — ADO leaves unresolved "
302+
f"macros as the literal string '$(name)')."
303+
)
304+
305+
306+
def parse_secret_str(name: str) -> str:
307+
"""Fail loudly if a SECURE pipeline variable was unresolved.
308+
309+
Same root cause as parse_bool: ADO leaves unresolved macros as
310+
'$(varname)'. For securestring parameters (envFileContents), Azure
311+
accepts ANY string, so an unresolved macro silently deploys garbage
312+
that the container can't parse. Detect the pattern and abort.
313+
"""
314+
val = os.environ.get(name, "")
315+
# Match exactly '$(identifier)' — ADO macro syntax for unresolved variables.
316+
if val.startswith("$(") and val.endswith(")") and "(" not in val[2:-1]:
317+
sys.exit(
318+
f"ERROR: Pipeline variable {name} appears unresolved (got "
319+
f"'{val}'). The ADO library variable is likely missing or "
320+
f"misspelled (ADO leaves unresolved macros as the literal "
321+
f"string '$(name)'). For envFileContents, also confirm the "
322+
f"variable is marked SECURE in the ADO library."
323+
)
324+
if not val:
325+
sys.exit(f"ERROR: Pipeline variable {name} is empty.")
326+
return val
327+
328+
329+
doc = {
330+
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
331+
"contentVersion": "1.0.0.0",
332+
"parameters": {
333+
"appName": {"value": os.environ["APP_NAME"]},
334+
"containerImage": {"value": os.environ["CONTAINER_IMAGE"]},
335+
"entraTenantId": {"value": os.environ["ENTRA_TENANT_ID"]},
336+
"entraClientId": {"value": os.environ["ENTRA_CLIENT_ID"]},
337+
"allowedGroupObjectIds": {"value": os.environ["ALLOWED_GROUP_OBJECT_IDS"]},
338+
"sqlServerFqdn": {"value": os.environ["SQL_SERVER_FQDN"]},
339+
"sqlDatabaseName": {"value": os.environ["SQL_DATABASE_NAME"]},
340+
"keyVaultResourceId": {"value": os.environ["KEY_VAULT_RESOURCE_ID"]},
341+
"acrResourceId": {"value": os.environ["ACR_RESOURCE_ID"]},
342+
"enablePrivateEndpoint": {"value": parse_bool("ENABLE_PRIVATE_ENDPOINT")},
343+
"enableOtel": {"value": parse_bool("ENABLE_OTEL")},
344+
"envFileContents": {"value": parse_secret_str("ENV_FILE_CONTENTS")},
345+
},
346+
}
347+
print(json.dumps(doc))
348+
PY
349+
192350
az deployment group create \
193351
--resource-group $(resourceGroup) \
194352
--template-file $(Build.SourcesDirectory)/infra/main.bicep \
195-
--parameters appName=$(appName) \
196-
--parameters containerImage=$(acrLoginServer)/$(imageName):$(Build.SourceVersion) \
197-
--parameters entraTenantId=$(entraTenantId) \
198-
--parameters entraClientId=$(entraClientId) \
199-
--parameters allowedGroupObjectIds="$(allowedGroupObjectIds)" \
200-
--parameters sqlServerFqdn=$(sqlServerFqdn) \
201-
--parameters sqlDatabaseName=$(sqlDatabaseName) \
202-
--parameters keyVaultResourceId=$(keyVaultResourceId) \
203-
--parameters acrResourceId=$(acrResourceId) \
204-
--parameters enablePrivateEndpoint=$(enablePrivateEndpoint) \
205-
--parameters enableOtel=$(enableOtel) \
206-
--parameters envSecretName=$(envSecretName)
353+
--parameters @"$PARAMS_FILE"
354+
env:
355+
APP_NAME: $(appName)
356+
CONTAINER_IMAGE: $(acrLoginServer)/$(imageName):$(Build.SourceVersion)
357+
ENTRA_TENANT_ID: $(entraTenantId)
358+
ENTRA_CLIENT_ID: $(entraClientId)
359+
ALLOWED_GROUP_OBJECT_IDS: $(allowedGroupObjectIds)
360+
SQL_SERVER_FQDN: $(sqlServerFqdn)
361+
SQL_DATABASE_NAME: $(sqlDatabaseName)
362+
KEY_VAULT_RESOURCE_ID: $(keyVaultResourceId)
363+
ACR_RESOURCE_ID: $(acrResourceId)
364+
ENABLE_PRIVATE_ENDPOINT: $(enablePrivateEndpoint)
365+
ENABLE_OTEL: $(enableOtel)
366+
ENV_FILE_CONTENTS: $(envFileContents)
207367

208368
- task: AzureCLI@2
209369
displayName: 'Health check'

0 commit comments

Comments
 (0)