From ba83eb11d0fe584496605e2a7062b3701d0d6339 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 19 Jun 2026 14:47:55 +0100 Subject: [PATCH 01/38] Add sealed container ACI confidential sample with SKR flow --- aci-samples/README.md | 1 + aci-samples/sealed-container/.gitignore | 8 + .../Build-SealedArtifacts.ps1 | 682 ++++++++++++++++++ .../Deploy-SealedContainer.ps1 | 209 ++++++ aci-samples/sealed-container/Dockerfile | 89 +++ aci-samples/sealed-container/README.md | 184 +++++ aci-samples/sealed-container/app.py | 436 +++++++++++ .../sealed-container/deployment-template.json | 118 +++ .../deployment-template.json.sig | 8 + aci-samples/sealed-container/entrypoint.py | 383 ++++++++++ .../sealed-container/policies/README.md | 80 ++ .../policies/firewall-policy.json | 99 +++ .../policies/skr-release-policy.json | 42 ++ aci-samples/sealed-container/requirements.txt | 3 + .../sealed-container/templates/index.html | 250 +++++++ 15 files changed, 2592 insertions(+) create mode 100644 aci-samples/sealed-container/.gitignore create mode 100644 aci-samples/sealed-container/Build-SealedArtifacts.ps1 create mode 100644 aci-samples/sealed-container/Deploy-SealedContainer.ps1 create mode 100644 aci-samples/sealed-container/Dockerfile create mode 100644 aci-samples/sealed-container/README.md create mode 100644 aci-samples/sealed-container/app.py create mode 100644 aci-samples/sealed-container/deployment-template.json create mode 100644 aci-samples/sealed-container/deployment-template.json.sig create mode 100644 aci-samples/sealed-container/entrypoint.py create mode 100644 aci-samples/sealed-container/policies/README.md create mode 100644 aci-samples/sealed-container/policies/firewall-policy.json create mode 100644 aci-samples/sealed-container/policies/skr-release-policy.json create mode 100644 aci-samples/sealed-container/requirements.txt create mode 100644 aci-samples/sealed-container/templates/index.html diff --git a/aci-samples/README.md b/aci-samples/README.md index 1a0991d..7eb5ecd 100644 --- a/aci-samples/README.md +++ b/aci-samples/README.md @@ -48,6 +48,7 @@ Scripts and samples for creating Confidential Azure Container Instances (ACIs) u | [Visual Attestation Demo](visual-attestation-demo/README.md) | Interactive web application demonstrating remote attestation via Microsoft Azure Attestation (MAA) using the SKR sidecar | | [Visual Attestation Demo v2](visual-attestation-demo-v2/README.md) 🆕 | Simplified ACI port that calls MAA **directly** from the Flask app via the upstream `get-snp-report` tool (no SKR sidecar). Side-by-side Confidential vs Standard SKU deployment to demonstrate falsifiability. | | [App + PostgreSQL Finance Demo](app-and-postgreSQL-demo/README.md) 🆕 | Confidential ACI with DCa/ECa AMD PostgreSQL, 5,000 financial transactions, Application Gateway, and 9 threat scenarios | +| [Sealed Container](sealed-container/README.md) 🆕 | Production-shaped sealed container: hand-authored restrictive CCE policy (no exec, no stdio, no logging), SKR-released AES key that unwraps an encrypted data bundle into tmpfs inside the TEE, signed SBOM + Trivy scan + cosign signatures over every artifact, signed top-level manifest with checksums. | --- diff --git a/aci-samples/sealed-container/.gitignore b/aci-samples/sealed-container/.gitignore new file mode 100644 index 0000000..e4d86e3 --- /dev/null +++ b/aci-samples/sealed-container/.gitignore @@ -0,0 +1,8 @@ +# Local build state — regenerated by Build-SealedArtifacts.ps1 +acr-config.json +deployment-params*.json + +# Local credentials / ephemeral keys (never commit) +*.priv.pem +*.private.pem +wrap-key.pem diff --git a/aci-samples/sealed-container/Build-SealedArtifacts.ps1 b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 new file mode 100644 index 0000000..2253e11 --- /dev/null +++ b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 @@ -0,0 +1,682 @@ +<# +.SYNOPSIS + Build, sign and attest the sealed-app container image and produce a complete + set of signed artifacts under artifacts/. + +.DESCRIPTION + Steps performed (in order): + + 1. Validate prerequisites (az with the confcom extension, docker OR + az acr build, optionally: cosign, syft, trivy). + 2. Create resource group + ACR if missing (or reuse an existing one + pinned in acr-config.json). + 3. Generate a fresh 32-byte AES-256 data-encryption key (DEK) and + produce the sealed data bundle at artifacts/sealed-data.enc using + a freshly-generated RSA-4096 keypair as the wrapping key. The + RSA PRIVATE key is then uploaded into Azure Key Vault Premium + (HSM-backed) with the SKR release policy from + policies/skr-release-policy.json. + 4. Render policies/firewall-policy.json with the deployer's /32 + substituted, write it to artifacts/, and compute its SHA-256. + 5. Build the container image (`az acr build` server-side, no local + Docker required) and capture the image digest. The rendered + firewall-policy.json is baked into the image. + 6. Run `az confcom acipolicygen` against deployment-template.json + to produce artifacts/cce-policy.rego (the only form of CCE + policy the Confidential ACI platform accepts), then compute + sha256(cce-policy) and base64-encode it. + 7. Render policies/skr-release-policy.json with that SHA-256 + (closing the chain) and import the wrap key into AKV with the + release policy attached. + 8. Generate an SBOM with syft (SPDX + CycloneDX). Fall back to a + minimal hand-rolled SBOM if syft is not installed. + 9. Run a vulnerability scan with trivy. Fall back to a stub report. + 10. Sign every artifact with cosign (if installed) and compute SHA-256 + checksums. + 11. Assemble artifacts/MANIFEST.json + MANIFEST.json.sig pointing at + every file with its digest, and refresh artifacts/checksums.sha256. + + No interactive prompts — fully scriptable. + +.PARAMETER Build + Build the image and produce all artifacts. Default action. + +.PARAMETER Refresh + Re-render the policies, SBOM, scan and signatures against the + already-built image. Skips the docker build. + +.PARAMETER Prefix + Resource naming prefix (default: sgall). Random 5-char suffix appended. + +.PARAMETER Location + Azure region (default: eastus). Must support Confidential ACI + Premium AKV. + +.PARAMETER SubscriptionId + Azure subscription to use. Defaults to current az context. + +.EXAMPLE + ./Build-SealedArtifacts.ps1 -Build +#> +[CmdletBinding(DefaultParameterSetName='Build')] +param( + [Parameter(ParameterSetName='Build')] [switch]$Build, + [Parameter(ParameterSetName='Refresh')] [switch]$Refresh, + [string]$Prefix = 'sgall', + [string]$Location = 'eastus', + [string]$SubscriptionId, + [string]$TrustedSourceCidr +) + +$ErrorActionPreference = 'Stop' +$OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$ScriptDir = $PSScriptRoot +$ArtifactDir = Join-Path $ScriptDir 'artifacts' +$PolicyDir = Join-Path $ScriptDir 'policies' +$ConfigPath = Join-Path $ScriptDir 'acr-config.json' +$ImageName = 'sealed-app' +$ImageTag = '1.0.0' + +if (-not (Test-Path $ArtifactDir)) { New-Item -ItemType Directory -Path $ArtifactDir -Force | Out-Null } + +# ---------------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------------- +function Write-Header { param($m) Write-Host ""; Write-Host ("=" * 72) -ForegroundColor Cyan; Write-Host $m -ForegroundColor Cyan; Write-Host ("=" * 72) -ForegroundColor Cyan } +function Write-Step { param($m) Write-Host "[STEP] $m" -ForegroundColor Cyan } +function Write-Success { param($m) Write-Host "[OK] $m" -ForegroundColor Green } +function Write-Warn2 { param($m) Write-Host "[WARN] $m" -ForegroundColor Yellow } + +function Test-Tool { + param([string]$Name) + $cmd = Get-Command $Name -ErrorAction SilentlyContinue + if ($cmd) { Write-Host " $Name found: $($cmd.Source)"; return $true } + Write-Warn2 "$Name not installed — falling back to placeholder output." + return $false +} + +function Get-Sha256 { + param([Parameter(Mandatory)] [string]$Path) + (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLower() +} + +function Save-Config { param($cfg) $cfg | ConvertTo-Json -Depth 10 | Set-Content $ConfigPath -Encoding UTF8 } +function Get-Config { if (Test-Path $ConfigPath) { Get-Content $ConfigPath -Raw | ConvertFrom-Json } } + +function Invoke-Az { + az @args + if ($LASTEXITCODE -ne 0) { throw "az $($args -join ' ') failed (exit $LASTEXITCODE)" } +} + +function Test-AzCli { + az version 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Azure CLI not found. Install from https://aka.ms/azcli" } + $acct = az account show 2>$null | ConvertFrom-Json + if (-not $acct) { throw "Not logged in. Run: az login" } + if ($SubscriptionId -and $acct.id -ne $SubscriptionId) { + Invoke-Az account set --subscription $SubscriptionId + $acct = az account show | ConvertFrom-Json + } + Write-Host "Subscription: $($acct.name) ($($acct.id))" + return $acct +} + +# ---------------------------------------------------------------------------- +# Sealed data bundle producer +# ---------------------------------------------------------------------------- +function New-SealedBundle { + <# + Produces: + artifacts/sealed-data.enc — encrypted on-disk bundle. + artifacts/wrap-key.pem (TEMP, deleted) — RSA-4096 priv key (uploaded to AKV then wiped). + artifacts/wrap-key.public.pem — public half (recorded for traceability). + Returns: + @{ DekHex, WrapPrivPem (string), WrapPubPem (string), CiphertextSha256 } + #> + Add-Type -AssemblyName System.Security + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + $dek = New-Object byte[] 32 ; $rng.GetBytes($dek) + $nonce = New-Object byte[] 12 ; $rng.GetBytes($nonce) + + # Plaintext sealed bundle — application secrets, sample data, splash text. + $plain = [ordered]@{ + sealed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + meta = @{ + description = "Demo sealed data bundle for sealed-app on ACI Confidential Containers." + owner = "Azure Confidential Computing samples" + } + files = @{ + 'welcome.txt' = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( + "Hello from inside the TEE. This file was never on disk in plaintext on the host.")) + 'api-token.txt' = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( + "demo-api-token-" + ([guid]::NewGuid().ToString('N')))) + 'config.json' = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( + (@{ tenant='demo'; tier='confidential'; rotation_days=30 } | ConvertTo-Json -Compress))) + } + } | ConvertTo-Json -Depth 10 -Compress + $plainBytes = [Text.Encoding]::UTF8.GetBytes($plain) + + # AES-256-GCM via .NET (PowerShell 7+) + $aes = [System.Security.Cryptography.AesGcm]::new($dek) + $ct = New-Object byte[] $plainBytes.Length + $tag = New-Object byte[] 16 + $aad = [Text.Encoding]::ASCII.GetBytes("sealed-app/v1") + $aes.Encrypt($nonce, $plainBytes, $ct, $tag, $aad) + $aes.Dispose() + # The cryptography library expects the auth tag appended to the ciphertext. + $ctTag = New-Object byte[] ($ct.Length + 16) + [Array]::Copy($ct, 0, $ctTag, 0, $ct.Length) + [Array]::Copy($tag, 0, $ctTag, $ct.Length, 16) + + # RSA-4096 wrapping key + $rsa = [System.Security.Cryptography.RSA]::Create(4096) + $wrapped = $rsa.Encrypt($dek, [System.Security.Cryptography.RSAEncryptionPadding]::OaepSHA256) + $privPem = $rsa.ExportRSAPrivateKeyPem() + $pubPem = $rsa.ExportRSAPublicKeyPem() + $rsa.Dispose() + + # Assemble the bundle: SEAL magic, version, [u32+wrapped][u32+nonce][u32+ct+tag] + $ms = New-Object System.IO.MemoryStream + $bw = New-Object System.IO.BinaryWriter $ms + $bw.Write([byte[]]([Text.Encoding]::ASCII.GetBytes("SEAL"))) + $bw.Write([uint32]1) + $bw.Write([uint32]$wrapped.Length); $bw.Write($wrapped) + $bw.Write([uint32]$nonce.Length); $bw.Write($nonce) + $bw.Write([uint32]$ctTag.Length); $bw.Write($ctTag) + $bw.Flush() + $bundlePath = Join-Path $ArtifactDir 'sealed-data.enc' + [System.IO.File]::WriteAllBytes($bundlePath, $ms.ToArray()) + $bw.Dispose() ; $ms.Dispose() + + $ctSha = Get-Sha256 $bundlePath + $pubPath = Join-Path $ArtifactDir 'wrap-key.public.pem' + $pubPem | Set-Content $pubPath -Encoding ASCII + + Write-Success "Sealed bundle written: $bundlePath ($ctSha)" + return @{ + DekHex = ($dek | ForEach-Object { '{0:x2}' -f $_ }) -join '' + WrapPrivPem = $privPem + WrapPubPem = $pubPem + CiphertextSha256 = $ctSha + } +} + +# ---------------------------------------------------------------------------- +# SBOM (syft) +# ---------------------------------------------------------------------------- +function New-Sbom { + param([string]$ImageRef) + $spdx = Join-Path $ArtifactDir 'sealed-app.sbom.spdx.json' + $cdx = Join-Path $ArtifactDir 'sealed-app.sbom.cyclonedx.json' + if (Test-Tool 'syft') { + Write-Step "Generating SBOM with syft" + syft "$ImageRef" -o "spdx-json=$spdx" -o "cyclonedx-json=$cdx" --quiet + if ($LASTEXITCODE -ne 0) { throw "syft failed" } + } else { + Write-Step "Writing placeholder SBOM (syft not installed)" + $requirements = Get-Content (Join-Path $ScriptDir 'requirements.txt') + $packages = $requirements | Where-Object { $_ -and ($_ -notmatch '^\s*#') } | + ForEach-Object { + $name, $rest = $_ -split '[><=!]', 2 + @{ name = $name.Trim(); versionRange = $_.Trim(); SPDXID = "SPDXRef-Package-$($name.Trim())" } + } + $obj = [ordered]@{ + spdxVersion = "SPDX-2.3" + dataLicense = "CC0-1.0" + SPDXID = "SPDXRef-DOCUMENT" + name = "sealed-app-placeholder" + documentNamespace = "https://example.invalid/spdxdocs/sealed-app/" + [guid]::NewGuid() + creationInfo = @{ created = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'); creators = @("Tool: Build-SealedArtifacts.ps1 (placeholder; install syft for real SBOM)") } + packages = $packages + _placeholder = $true + } + $obj | ConvertTo-Json -Depth 10 | Set-Content $spdx -Encoding UTF8 + # CycloneDX placeholder + $cdxObj = [ordered]@{ + bomFormat = "CycloneDX" ; specVersion = "1.5" ; version = 1 + metadata = @{ timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'); tools = @(@{ vendor = "Build-SealedArtifacts.ps1"; name = "placeholder" }) } + components = $packages | ForEach-Object { @{ type = "library"; name = $_.name; version = $_.versionRange } } + _placeholder = $true + } + $cdxObj | ConvertTo-Json -Depth 10 | Set-Content $cdx -Encoding UTF8 + } + Write-Success "SBOM: $spdx + $cdx" + return @{ Spdx = $spdx; Cdx = $cdx } +} + +# ---------------------------------------------------------------------------- +# Vulnerability scan (trivy) +# ---------------------------------------------------------------------------- +function Invoke-Scan { + param([string]$ImageRef) + $jsonPath = Join-Path $ArtifactDir 'trivy-report.json' + $mdPath = Join-Path $ArtifactDir 'trivy-report.summary.md' + if (Test-Tool 'trivy') { + Write-Step "Scanning image with trivy" + trivy image --quiet --format json --output $jsonPath $ImageRef + if ($LASTEXITCODE -ne 0) { throw "trivy failed" } + trivy image --quiet --format table --severity CRITICAL,HIGH,MEDIUM --output $mdPath $ImageRef + } else { + Write-Step "Writing placeholder vulnerability report (trivy not installed)" + $stub = [ordered]@{ + ArtifactName = $ImageRef + ArtifactType = "container_image" + Metadata = @{ ScannedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'); Scanner = "placeholder" } + Results = @() + _placeholder = $true + _instructions = "Install trivy (https://aquasecurity.github.io/trivy/) and re-run with -Refresh." + } + $stub | ConvertTo-Json -Depth 10 | Set-Content $jsonPath -Encoding UTF8 + @" +# Vulnerability scan summary (placeholder) + +trivy is not installed in this environment. Run: + +```powershell +choco install trivy # or: scoop install trivy +./Build-SealedArtifacts.ps1 -Refresh +``` + +After a real scan this file will list every CVE in the image grouped by +severity, with fixed-version columns and a signed JSON report alongside. +"@ | Set-Content $mdPath -Encoding UTF8 + } + Write-Success "Scan report: $jsonPath + $mdPath" + return @{ Json = $jsonPath; Md = $mdPath } +} + +# ---------------------------------------------------------------------------- +# Signing (cosign) +# ---------------------------------------------------------------------------- +function Sign-Artifact { + param( + [string]$Path, + [string]$KeyRef # cosign key path or KMS ref. If null, ephemeral keyless OIDC. + ) + $sigPath = "$Path.sig" + if (Test-Tool 'cosign') { + # Use sign-blob for detached signatures over arbitrary files. + $cmd = @('sign-blob', '--yes', '--output-signature', $sigPath, $Path) + if ($KeyRef) { $cmd = @('sign-blob', '--yes', '--key', $KeyRef, '--output-signature', $sigPath, $Path) } + cosign @cmd 2>&1 | ForEach-Object { Write-Host " $_" } + if ($LASTEXITCODE -ne 0) { throw "cosign sign-blob failed for $Path" } + } else { + # Stub signature: a JSON document recording the hash + a clear note. + @{ + _placeholder = $true + note = "cosign is not installed — install it and re-run -Refresh for a real signature." + algorithm = "sha256" + digest = Get-Sha256 $Path + signed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + file = (Split-Path -Leaf $Path) + } | ConvertTo-Json -Depth 10 | Set-Content $sigPath -Encoding UTF8 + } + return $sigPath +} + +# ---------------------------------------------------------------------------- +# Policy rendering +# ---------------------------------------------------------------------------- +function Render-SkrPolicy { + <# + Renders policies/skr-release-policy.json (source of truth) with the + target MAA endpoint and the CCE policy SHA-256, then writes the + inner `.policy` object to artifacts/skr-release-policy.json (the + AKV-acceptable form). Returns the path + sha256. + #> + param( + [string]$MaaEndpoint, + [string]$CcePolicySha256 + ) + $releasePath = Join-Path $ArtifactDir 'skr-release-policy.json' + $releaseSrcRaw = Get-Content (Join-Path $PolicyDir 'skr-release-policy.json') -Raw + $releaseSrcRaw = $releaseSrcRaw ` + -replace '__MAA_ENDPOINT__', $MaaEndpoint ` + -replace '__CCE_POLICY_SHA256_HEX__', $CcePolicySha256 + $releaseObj = $releaseSrcRaw | ConvertFrom-Json + $releaseAkvJson = $releaseObj.policy | ConvertTo-Json -Depth 20 + Set-Content $releasePath $releaseAkvJson -Encoding UTF8 + $releaseSha = Get-Sha256 $releasePath + Write-Success "skr-release-policy.json sha256 = $releaseSha" + return @{ Path = $releasePath; Sha256 = $releaseSha } +} + +function Invoke-Confcom { + <# + Renders a temporary, fully-concrete ARM parameters file for the + deployment template (every value confcom needs to compute the + policy is filled in), then runs `az confcom acipolicygen` to + produce artifacts/cce-policy.rego. Returns the path, the raw + bytes' sha256, and the base64-encoded form that goes into + deployment-template.json's ccePolicyBase64 parameter at deploy + time. + + confcom-generated policies are the ONLY form the ACI control + plane accepts — hand-authored Rego is silently rejected at + SNP_LAUNCH_FINISH and the container never reaches the image-pull + stage. See the Unsupported Scenarios section of + https://learn.microsoft.com/azure/container-instances/container-instances-confidential-overview + #> + param( + [hashtable]$Params, + [string]$RegistryName, + [string]$SubscriptionId + ) + $cceRawPath = Join-Path $ArtifactDir 'cce-policy.rego' + $tmpParams = Join-Path ([IO.Path]::GetTempPath()) ("sealed-confcom-" + [guid]::NewGuid().ToString('N') + '.json') + + $paramsDoc = @{ + '$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#' + contentVersion = '1.0.0.0' + parameters = @{} + } + foreach ($k in $Params.Keys) { $paramsDoc.parameters[$k] = @{ value = $Params[$k] } } + $paramsDoc | ConvertTo-Json -Depth 10 | Set-Content $tmpParams -Encoding UTF8 + + Write-Step "Generating CCE policy with az confcom acipolicygen" + try { + # confcom shells out to the local docker daemon to pull the image + # and hash its layers, so the daemon needs an ACR token. `az acr + # login` writes one into the docker credential store for us. + $loginArgs = @('acr', 'login', '--name', $RegistryName) + if ($SubscriptionId) { $loginArgs += @('--subscription', $SubscriptionId) } + az @loginArgs 2>&1 | ForEach-Object { Write-Host " $_" } + if ($LASTEXITCODE -ne 0) { throw "az acr login failed (exit $LASTEXITCODE)" } + + # --outraw → write raw (un-base64'd) policy text to the file + # --save-to-file → write the policy to disk + # -y → auto-approve wildcards prompt (we don't use any, + # but the tool prompts in some cases anyway) + $deployTemplate = Join-Path $ScriptDir 'deployment-template.json' + az confcom acipolicygen --template-file $deployTemplate --parameters $tmpParams ` + --save-to-file $cceRawPath --outraw -y 2>&1 | ForEach-Object { Write-Host " $_" } + if ($LASTEXITCODE -ne 0) { throw "az confcom acipolicygen failed (exit $LASTEXITCODE)" } + if (-not (Test-Path $cceRawPath)) { throw "confcom did not produce $cceRawPath" } + } finally { + Remove-Item $tmpParams -Force -ErrorAction SilentlyContinue + } + + $cceBytes = [System.IO.File]::ReadAllBytes($cceRawPath) + $cceSha = ([System.BitConverter]::ToString([System.Security.Cryptography.SHA256]::HashData($cceBytes))).Replace('-', '').ToLower() + $cceB64 = [Convert]::ToBase64String($cceBytes) + Write-Success "cce-policy.rego sha256 = $cceSha ($($cceBytes.Length) bytes)" + return @{ + Path = $cceRawPath + Sha256 = $cceSha + Base64 = $cceB64 + } +} + +# ---------------------------------------------------------------------------- +# Firewall policy rendering +# ---------------------------------------------------------------------------- +function Get-DeployerCidr { + if ($TrustedSourceCidr) { return $TrustedSourceCidr } + try { + $ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 8).Trim() + if ($ip -match '^\d{1,3}(\.\d{1,3}){3}$') { return "$ip/32" } + } catch { } + Write-Warn2 "Could not detect deployer public IP; defaulting trustedSourceCidr to 0.0.0.0/0 (NOT recommended). Override with -TrustedSourceCidr." + return '0.0.0.0/0' +} + +function Render-Firewall { + <# + Renders policies/firewall-policy.json (the SOURCE OF TRUTH) by + substituting __TRUSTED_SOURCE_CIDR__ with the detected/overridden + CIDR, writes it to artifacts/, and returns its sha256 + the CIDR + used. Because Confidential ACI does not allow VNet integration + without a NAT gateway, this policy is enforced AT THE APP + (app.py before_request hook) instead of by an NSG. The CCE + policy bakes the resulting sha256 + CIDR into the container's + env vars so a tampered firewall file fails the in-process + verification at startup. + #> + param([string]$Cidr) + $srcPolicy = Join-Path $PolicyDir 'firewall-policy.json' + $dstPolicy = Join-Path $ArtifactDir 'firewall-policy.json' + $raw = Get-Content $srcPolicy -Raw + $raw = $raw -replace '__TRUSTED_SOURCE_CIDR__', $Cidr + Set-Content $dstPolicy $raw -Encoding UTF8 + $sha = Get-Sha256 $dstPolicy + Write-Success "firewall-policy.json sha256 = $sha" + Write-Host " trusted source CIDR: $Cidr" + return @{ + PolicyPath = $dstPolicy + PolicySha256 = $sha + TrustedSourceCidr = $Cidr + } +} + +# ---------------------------------------------------------------------------- +# Top-level orchestration +# ---------------------------------------------------------------------------- +function Invoke-Build { + Write-Header "sealed-app — build, attest and sign" + Test-AzCli | Out-Null + + $cfg = Get-Config + if (-not $cfg) { + $rand = -join ((97..122) | Get-Random -Count 5 | ForEach-Object { [char]$_ }) + $cfg = [pscustomobject]@{ + basename = "$Prefix-sealed-$rand" + resourceGroup = "$Prefix-sealed-$rand-rg" + registry = "acr$Prefix$rand" + keyVault = "kv$Prefix$rand" + identity = "id$Prefix$rand" + location = $Location + keyName = 'sealed-app-wrap-key' + maaEndpoint = "sharedeus.eus.attest.azure.net" + } + } + Write-Host "Resource group: $($cfg.resourceGroup)" + Write-Host "ACR : $($cfg.registry)" + Write-Host "Key Vault : $($cfg.keyVault)" + Write-Host "Identity : $($cfg.identity)" + Write-Host "Location : $($cfg.location)" + + Write-Step "Ensuring Azure resources (group, ACR, identity, KV, SKR key)" + Invoke-Az group create --name $cfg.resourceGroup --location $cfg.location --output none + + # ACR — create only if missing (az acr create errors on already-exists). + $acrExists = az acr show --name $cfg.registry --resource-group $cfg.resourceGroup 2>$null + if (-not $acrExists) { + Invoke-Az acr create --resource-group $cfg.resourceGroup --name $cfg.registry --sku Basic --admin-enabled true --output none + } + $loginServer = az acr show --name $cfg.registry --query loginServer -o tsv + $cfg | Add-Member -NotePropertyName loginServer -NotePropertyValue $loginServer -Force + + # User-assigned managed identity — idempotent on `create` but other CLI + # versions complain, so guard the same way. + $idExists = az identity show --resource-group $cfg.resourceGroup --name $cfg.identity 2>$null + if (-not $idExists) { + Invoke-Az identity create --resource-group $cfg.resourceGroup --name $cfg.identity --location $cfg.location --output none + } + $identityJson = az identity show --resource-group $cfg.resourceGroup --name $cfg.identity | ConvertFrom-Json + $cfg | Add-Member -NotePropertyName identityId -NotePropertyValue $identityJson.id -Force + $cfg | Add-Member -NotePropertyName identityClientId -NotePropertyValue $identityJson.clientId -Force + $cfg | Add-Member -NotePropertyName identityPrincipalId -NotePropertyValue $identityJson.principalId -Force + + # Premium AKV with purge protection (required for HSM-backed exportable keys). + # `az keyvault create` errors hard when the vault already exists in any + # subscription on the tenant — guard with a show first. + $kvExists = az keyvault show --name $cfg.keyVault --resource-group $cfg.resourceGroup 2>$null + if (-not $kvExists) { + Invoke-Az keyvault create --resource-group $cfg.resourceGroup --name $cfg.keyVault --location $cfg.location ` + --sku Premium --enable-purge-protection true --enable-rbac-authorization false --output none + } + + Invoke-Az keyvault set-policy --name $cfg.keyVault --object-id $cfg.identityPrincipalId ` + --key-permissions get release wrapKey unwrapKey --output none + + Write-Step "Creating sealed data bundle and RSA-4096 wrap key" + $sealed = New-SealedBundle + $akvEndpoint = "$($cfg.keyVault).vault.azure.net" + + Save-Config $cfg + + # The actual AKV import + release-policy attach happens AFTER policy + # rendering below so we have the final policy hash to bind to the key. + + # Render the firewall policy BEFORE building the image, because the + # image bakes artifacts/firewall-policy.json so the app can verify its + # SHA-256 against the FIREWALL_POLICY_SHA256 env var at startup. + $cidr = Get-DeployerCidr + Write-Step "Rendering firewall policy (trusted CIDR = $cidr)" + $fw = Render-Firewall -Cidr $cidr + + if (-not $Refresh) { + Write-Step "Building image server-side with az acr build" + # NB: at this point sealed-data.enc AND firewall-policy.json both + # exist under artifacts/, so the Dockerfile COPYs succeed. + Invoke-Az acr build --registry $cfg.registry --image "${ImageName}:${ImageTag}" --file Dockerfile --no-logs $ScriptDir + } + + $manifest = az acr repository show-manifests --name $cfg.registry --repository $ImageName --query "[?tags && contains(tags,'${ImageTag}')] | [0]" -o json | ConvertFrom-Json + if (-not $manifest) { throw "Image ${ImageName}:${ImageTag} not found in $($cfg.registry) — build failed?" } + $imageDigest = $manifest.digest + $imageRef = "$loginServer/${ImageName}@$imageDigest" + # Layer dm-verity hashes are produced by `az acr manifest show` for ORAS-compliant images. + $layerInfo = az acr manifest show --registry $cfg.registry --name "$ImageName@$imageDigest" -o json | ConvertFrom-Json + $layerDigests = @($layerInfo.layers | ForEach-Object { $_.digest }) + Write-Success "Image: $imageRef ($($layerDigests.Count) layer(s))" + + # Pull ACR creds NOW so confcom can authenticate when it inspects the + # private image to enumerate layers. + $acrUser = az acr credential show --name $cfg.registry --query username -o tsv + $acrPass = az acr credential show --name $cfg.registry --query "passwords[0].value" -o tsv + if (-not $acrUser -or -not $acrPass) { throw "Failed to read ACR admin credentials for confcom" } + + # Generate the CCE policy with az confcom acipolicygen against the + # actual deployment template + concrete parameters. The platform + # ONLY accepts confcom-generated policies; hand-authored Rego is + # silently rejected at SNP_LAUNCH_FINISH and the deployment then + # hits the 30-minute provisioning timeout with zero events. + $confcomParams = @{ + containerGroupName = "sealed-app-cg" + location = $cfg.location + dnsNameLabel = "sealed-app" + appImage = $imageRef + registryServer = $loginServer + registryUsername = $acrUser + registryPassword = $acrPass + managedIdentityId = $cfg.identityId + maaEndpoint = $cfg.maaEndpoint + akvEndpoint = $akvEndpoint + skrKeyName = $cfg.keyName + releasePolicySha256 = ('0' * 64) # tag-only; not in container env + firewallPolicySha256 = $fw.PolicySha256 + trustedSourceCidr = $fw.TrustedSourceCidr + ccePolicyBase64 = 'placeholder' # confcom replaces this on injection paths; we ignore the in-template value + } + $cce = Invoke-Confcom -Params $confcomParams -RegistryName $cfg.registry + + Write-Step "Rendering SKR release policy (binds to CCE SHA-256)" + $skr = Render-SkrPolicy -MaaEndpoint $cfg.maaEndpoint -CcePolicySha256 $cce.Sha256 + + Write-Step "Importing wrap key into Azure Key Vault with SKR release policy" + $tmpPem = Join-Path ([IO.Path]::GetTempPath()) ("wrap-key-$([guid]::NewGuid().ToString('N')).pem") + Set-Content $tmpPem $sealed.WrapPrivPem -Encoding ASCII + try { + Invoke-Az keyvault key import --vault-name $cfg.keyVault --name $cfg.keyName ` + --pem-file $tmpPem --protection hsm --ops decrypt unwrapKey --exportable true ` + --policy (Resolve-Path $skr.Path).Path --output none + } finally { + Remove-Item $tmpPem -Force -ErrorAction SilentlyContinue + } + Write-Success "Wrap key $($cfg.keyName) imported into $($cfg.keyVault) and bound to SKR release policy" + + $sbom = New-Sbom -ImageRef $imageRef + $scan = Invoke-Scan -ImageRef $imageRef + + Write-Step "Signing artifacts" + $signed = @() + foreach ($p in @( + $cce.Path, $skr.Path, + $fw.PolicyPath, + $sbom.Spdx, $sbom.Cdx, + $scan.Json, $scan.Md, + (Join-Path $ArtifactDir 'sealed-data.enc'), + (Join-Path $ScriptDir 'deployment-template.json') + )) { + $sig = Sign-Artifact -Path $p + $signed += [pscustomobject]@{ file = (Split-Path -Leaf $p); sig = (Split-Path -Leaf $sig); sha256 = (Get-Sha256 $p) } + } + + Write-Step "Writing MANIFEST.json + checksums.sha256" + $manifestObj = [ordered]@{ + schema = "https://github.com/Azure-Samples/confidential-computing/sealed-container/manifest-v1" + produced_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + image = @{ + reference = $imageRef + digest = $imageDigest + layers = $layerDigests + registry = $cfg.registry + } + policies = @{ + cce_policy_sha256 = $cce.Sha256 + release_policy_sha256 = $skr.Sha256 + } + firewall = @{ + policy_sha256 = $fw.PolicySha256 + trusted_source_cidr = $fw.TrustedSourceCidr + allowed_ingress_port = 8443 + enforcement = "L7 (app.py before_request) — Confidential ACI does not support NSG without NAT gateway" + default_action = "deny" + } + akv = @{ + endpoint = $akvEndpoint + key_name = $cfg.keyName + } + managed_identity = @{ + name = $cfg.identity + client_id = $cfg.identityClientId + resource_id = $cfg.identityId + } + sealed_data = @{ + file = "sealed-data.enc" + ciphertext_sha256 = $sealed.CiphertextSha256 + wrap_algorithm = "RSA-OAEP-SHA256 + AES-256-GCM" + wrap_key_public = "wrap-key.public.pem" + } + files = $signed + } + $manifestPath = Join-Path $ArtifactDir 'MANIFEST.json' + $manifestObj | ConvertTo-Json -Depth 10 | Set-Content $manifestPath -Encoding UTF8 + Sign-Artifact -Path $manifestPath | Out-Null + + $checksumPath = Join-Path $ArtifactDir 'checksums.sha256' + Get-ChildItem $ArtifactDir -File | Where-Object { $_.Name -notin @('checksums.sha256') } | + Sort-Object Name | ForEach-Object { + "$(Get-Sha256 $_.FullName) $($_.Name)" + } | Set-Content $checksumPath -Encoding ASCII + Write-Success "Checksums written: $checksumPath" + + # Update acr-config so Deploy-SealedContainer.ps1 can pick up everything. + $cfg | Add-Member -NotePropertyName imageRef -NotePropertyValue $imageRef -Force + $cfg | Add-Member -NotePropertyName imageDigest -NotePropertyValue $imageDigest -Force + $cfg | Add-Member -NotePropertyName ccePolicyBase64 -NotePropertyValue $cce.Base64 -Force + $cfg | Add-Member -NotePropertyName ccePolicySha256 -NotePropertyValue $cce.Sha256 -Force + $cfg | Add-Member -NotePropertyName releasePolicySha256 -NotePropertyValue $skr.Sha256 -Force + $cfg | Add-Member -NotePropertyName firewallPolicySha256 -NotePropertyValue $fw.PolicySha256 -Force + $cfg | Add-Member -NotePropertyName trustedSourceCidr -NotePropertyValue $fw.TrustedSourceCidr -Force + $cfg | Add-Member -NotePropertyName akvEndpoint -NotePropertyValue $akvEndpoint -Force + Save-Config $cfg + + Write-Header "Build complete" + Write-Host "Artifacts directory: $ArtifactDir" + Write-Host "Image: $imageRef" + Write-Host "" + Write-Host "Next step:" + Write-Host " ./Deploy-SealedContainer.ps1 -Deploy" +} + +# ---------------------------------------------------------------------------- +# Entry point +# ---------------------------------------------------------------------------- +if ($Refresh -or $Build -or $PSCmdlet.ParameterSetName -eq 'Build') { + Invoke-Build +} else { + Get-Help $MyInvocation.MyCommand.Path -Full +} diff --git a/aci-samples/sealed-container/Deploy-SealedContainer.ps1 b/aci-samples/sealed-container/Deploy-SealedContainer.ps1 new file mode 100644 index 0000000..43e2bc0 --- /dev/null +++ b/aci-samples/sealed-container/Deploy-SealedContainer.ps1 @@ -0,0 +1,209 @@ +<# +.SYNOPSIS + Deploy the sealed-app container group to ACI Confidential SKU using the + artifacts produced by Build-SealedArtifacts.ps1. + +.DESCRIPTION + Reads acr-config.json (written by Build-SealedArtifacts.ps1) plus the + confcom-generated CCE policy under artifacts/cce-policy.rego, then + submits an ARM deployment of deployment-template.json with the right + parameters. + + The container is exposed on a PUBLIC IP because Confidential ACI does + not support VNet integration without a NAT gateway. Ingress is locked + down at L7 by the app (see app.py before_request) using the signed + artifacts/firewall-policy.json baked into the image. + + There is intentionally NO Standard-SKU variant of this deployment: the + SKR release policy refuses to release the wrapping key off SEV-SNP, so a + Standard deploy would crash-loop forever and is more confusing than + educational. + +.PARAMETER Deploy + Submit the ARM deployment. Default action. + +.PARAMETER Cleanup + Delete the container group (NOT the AKV / ACR — use the full RG cleanup + in Build-SealedArtifacts.ps1 for that). + +.PARAMETER SkipBrowser + Don't open the FQDN after a successful deploy. + +.PARAMETER Verify + Re-check artifacts/checksums.sha256 against the files on disk; warn if + anything has drifted since the last build. +#> +[CmdletBinding(DefaultParameterSetName='Deploy')] +param( + [Parameter(ParameterSetName='Deploy')] [switch]$Deploy, + [Parameter(ParameterSetName='Cleanup')] [switch]$Cleanup, + [Parameter(ParameterSetName='Verify')] [switch]$Verify, + [Parameter(ParameterSetName='Deploy')] [switch]$SkipBrowser +) + +$ErrorActionPreference = 'Stop' +$ScriptDir = $PSScriptRoot +$ArtifactDir = Join-Path $ScriptDir 'artifacts' +$ConfigPath = Join-Path $ScriptDir 'acr-config.json' + +function Write-Header { param($m) Write-Host ""; Write-Host ("=" * 72) -ForegroundColor Cyan; Write-Host $m -ForegroundColor Cyan; Write-Host ("=" * 72) -ForegroundColor Cyan } +function Write-Step { param($m) Write-Host "[STEP] $m" -ForegroundColor Cyan } +function Write-Success { param($m) Write-Host "[OK] $m" -ForegroundColor Green } +function Write-Warn2 { param($m) Write-Host "[WARN] $m" -ForegroundColor Yellow } + +function Get-Cfg { + if (-not (Test-Path $ConfigPath)) { + throw "acr-config.json not found. Run ./Build-SealedArtifacts.ps1 -Build first." + } + return Get-Content $ConfigPath -Raw | ConvertFrom-Json +} + +function Get-Sha256 { param($Path) (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLower() } + +# ---------------------------------------------------------------------------- +# Verify +# ---------------------------------------------------------------------------- +function Invoke-Verify { + Write-Header "Verifying artifacts/checksums.sha256" + $checksumPath = Join-Path $ArtifactDir 'checksums.sha256' + if (-not (Test-Path $checksumPath)) { throw "$checksumPath not found." } + $bad = 0; $good = 0 + foreach ($line in Get-Content $checksumPath) { + if (-not $line.Trim()) { continue } + $parts = $line -split '\s+', 2 + $expected = $parts[0] + $name = $parts[1].TrimStart('*') + $path = Join-Path $ArtifactDir $name + if (-not (Test-Path $path)) { Write-Warn2 "MISSING : $name"; $bad++; continue } + $actual = Get-Sha256 $path + if ($actual -eq $expected) { + Write-Host " OK $name" -ForegroundColor Green + $good++ + } else { + Write-Host " MISMATCH $name" -ForegroundColor Red + Write-Host " expected: $expected" -ForegroundColor DarkGray + Write-Host " actual: $actual" -ForegroundColor DarkGray + $bad++ + } + } + Write-Host "" + if ($bad -eq 0) { Write-Success "All $good artifacts match their recorded SHA-256." ; return 0 } + Write-Warn2 "$bad artifacts failed verification." + return 1 +} + +# ---------------------------------------------------------------------------- +# Cleanup (container group only) +# ---------------------------------------------------------------------------- +function Invoke-Cleanup { + Write-Header "Cleanup — deleting container group" + $cfg = Get-Cfg + $cgName = "$($cfg.basename)-cg" + Write-Host "Deleting container group(s) matching: $cgName*" + $cgList = az container list --resource-group $cfg.resourceGroup --query "[?starts_with(name, '$cgName')].name" -o tsv + foreach ($n in ($cgList -split "`n" | Where-Object { $_ })) { + Write-Host " - $n" + az container delete --resource-group $cfg.resourceGroup --name $n --yes | Out-Null + } + Write-Success "Cleanup done." +} + +# ---------------------------------------------------------------------------- +# Deploy +# ---------------------------------------------------------------------------- +function Invoke-Deploy { + Write-Header "Deploying sealed-app to ACI Confidential SKU (public IP, L7 firewall)" + $cfg = Get-Cfg + + if (-not $cfg.ccePolicyBase64 -or -not $cfg.imageRef) { + throw "acr-config.json is incomplete — re-run ./Build-SealedArtifacts.ps1 -Build." + } + if (-not $cfg.trustedSourceCidr) { + throw "acr-config.json is missing trustedSourceCidr — re-run ./Build-SealedArtifacts.ps1 -Build." + } + + # Re-verify checksums before deploying; refuse if anything drifted. + if ((Invoke-Verify) -ne 0) { + throw "Artifact verification failed. Refusing to deploy." + } + + # Pull ACR creds + $u = az acr credential show --name $cfg.registry --query username -o tsv + $p = az acr credential show --name $cfg.registry --query "passwords[0].value" -o tsv + if (-not $u -or -not $p) { throw "Failed to read ACR admin credentials" } + + $stamp = Get-Date -Format 'MMddHHmm' + $cgName = "$($cfg.basename)-cg-$stamp" + $dnsNameLabel = "$($cfg.basename)-$stamp".ToLower() -replace '[^a-z0-9-]', '' + if ($dnsNameLabel.Length -gt 63) { $dnsNameLabel = $dnsNameLabel.Substring(0, 63) } + + $paramsObj = @{ + '$schema' = "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#" + contentVersion = "1.0.0.0" + parameters = @{ + containerGroupName = @{ value = $cgName } + location = @{ value = $cfg.location } + dnsNameLabel = @{ value = $dnsNameLabel } + appImage = @{ value = $cfg.imageRef } + registryServer = @{ value = $cfg.loginServer } + registryUsername = @{ value = $u } + registryPassword = @{ value = $p } + managedIdentityId = @{ value = $cfg.identityId } + maaEndpoint = @{ value = $cfg.maaEndpoint } + akvEndpoint = @{ value = $cfg.akvEndpoint } + skrKeyName = @{ value = $cfg.keyName } + releasePolicySha256 = @{ value = $cfg.releasePolicySha256 } + firewallPolicySha256 = @{ value = $cfg.firewallPolicySha256 } + trustedSourceCidr = @{ value = $cfg.trustedSourceCidr } + ccePolicyBase64 = @{ value = $cfg.ccePolicyBase64 } + } + } + $tmpParams = Join-Path ([IO.Path]::GetTempPath()) "sealed-deploy-$([guid]::NewGuid().ToString('N')).json" + $paramsObj | ConvertTo-Json -Depth 10 | Set-Content $tmpParams -Encoding UTF8 + try { + Write-Step "Submitting ARM deployment ($cgName)" + $deployJson = az deployment group create ` + --resource-group $cfg.resourceGroup ` + --name $cgName ` + --template-file (Join-Path $ScriptDir 'deployment-template.json') ` + --parameters "@$tmpParams" -o json + if ($LASTEXITCODE -ne 0) { throw "az deployment group create failed" } + } finally { + Remove-Item $tmpParams -Force -ErrorAction SilentlyContinue + } + + $outputs = ($deployJson | ConvertFrom-Json).properties.outputs + $publicIp = $outputs.publicIp.value + $fqdn = $outputs.fqdn.value + $url = $outputs.url.value + + Write-Success "Deployed." + Write-Host " Container group: $cgName" + Write-Host " Public IP: $publicIp" + Write-Host " FQDN: $fqdn" + Write-Host " URL: $url" + Write-Host "" + Write-Host " L7 firewall (enforced by app.py) only accepts requests from:" + Write-Host " $($cfg.trustedSourceCidr)" + Write-Host " Anything else gets a 403 before any route runs." + Write-Host " /healthz bypasses the firewall (so the platform probe works)." + Write-Host "" + Write-Host "View logs: az container logs -g $($cfg.resourceGroup) -n $cgName --container-name sealed-app" + Write-Host "Try exec: az container exec -g $($cfg.resourceGroup) -n $cgName --exec-command /bin/sh" + Write-Host " ^ will FAIL by design — confcom-generated CCE policy denies exec_processes" + Write-Host "Cleanup: ./Deploy-SealedContainer.ps1 -Cleanup" + Write-Host "" + + if (-not $SkipBrowser -and $url) { + Start-Process $url -ErrorAction SilentlyContinue + } +} + +# ---------------------------------------------------------------------------- +# Dispatch +# ---------------------------------------------------------------------------- +switch ($PSCmdlet.ParameterSetName) { + 'Cleanup' { Invoke-Cleanup } + 'Verify' { Invoke-Verify | Out-Null } + default { Invoke-Deploy } +} diff --git a/aci-samples/sealed-container/Dockerfile b/aci-samples/sealed-container/Dockerfile new file mode 100644 index 0000000..f490d8b --- /dev/null +++ b/aci-samples/sealed-container/Dockerfile @@ -0,0 +1,89 @@ +# sealed-app image — restrictive confidential ACI container. +# +# Properties: +# * Multi-stage build. Stage 1 compiles `get-snp-report` from the upstream +# microsoft/confidential-sidecar-containers repo at a pinned tag. Only the +# resulting static binary is copied into the runtime stage. +# * Runtime stage starts from a pinned python:3.11-slim digest (replace the +# ${BASE_IMAGE_DIGEST} ARG at build time with the actual `sha256:...` +# pulled from your registry to make this fully reproducible). +# * No shell, no package manager, no compilers in the final image. We use +# pip wheels only — no source builds at runtime. +# * Non-root user `sealed` (uid 10001). +# * No HEALTHCHECK directive (the ACI control plane is the only thing that +# ever probes us, and the CCE policy denies arbitrary exec). +# * The sealed data bundle (`sealed-data.enc`) is BAKED INTO the image so +# the image digest covers it too — meaning the SKR release policy, which +# is keyed off the CCE policy hash (which is itself keyed off the image +# digest via the fragment) cryptographically binds the sealed payload to +# the only container that can ever decrypt it. + +ARG SIDECAR_TAG=v2.14 +# Base images are pulled from the Microsoft Artifact Registry (MCR) mirror, +# not Docker Hub. ACR build agents share a public NAT and routinely hit +# Docker Hub's unauthenticated pull rate limit; MCR has no such limit and +# is itself an air-gapped, signed mirror of the upstream library images. +ARG BASE_IMAGE=mcr.microsoft.com/mirror/docker/library/python:3.11-slim +ARG BASE_IMAGE_DIGEST=sha256:replace-with-pulled-digest + +# --------------------------------------------------------------------------- +# Stage 1 — build get-snp-report from source. +# --------------------------------------------------------------------------- +FROM mcr.microsoft.com/mirror/docker/library/debian:bookworm-slim AS snp-build +ARG SIDECAR_TAG +RUN apt-get update \ + && apt-get install -y --no-install-recommends git make gcc libc6-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 --branch ${SIDECAR_TAG} \ + https://github.com/microsoft/confidential-sidecar-containers.git /src +WORKDIR /src/tools/get-snp-report +# Build the SNP binary, then regenerate its sha256 manifest using only the +# basename so the runtime stage can verify it from /usr/local/bin without +# carrying over the build-stage relative path (`bin/get-snp-report`). +RUN make bin/get-snp-report \ + && (cd bin && sha256sum get-snp-report > get-snp-report.sha256) + +# --------------------------------------------------------------------------- +# Stage 2 — runtime image. +# --------------------------------------------------------------------------- +FROM ${BASE_IMAGE} + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PORT=8443 \ + SEALED_DIR=/run/sealed \ + SEALED_BUNDLE=/app/sealed-data.enc \ + GET_SNP_REPORT=/usr/local/bin/get-snp-report + +# Single layer for everything user-code-related: copy deps, copy code, copy +# the SNP binary with its checksum, create the unprivileged user, strip +# setuid/setgid bits, and make /app read-only friendly. +COPY requirements.txt /tmp/requirements.txt +COPY app.py entrypoint.py /app/ +COPY templates/ /app/templates/ +COPY artifacts/sealed-data.enc /app/sealed-data.enc +COPY artifacts/firewall-policy.json /app/firewall-policy.json +COPY --from=snp-build /src/tools/get-snp-report/bin/get-snp-report /usr/local/bin/get-snp-report +COPY --from=snp-build /src/tools/get-snp-report/bin/get-snp-report.sha256 /usr/local/bin/get-snp-report.sha256 + +RUN pip install --no-cache-dir -r /tmp/requirements.txt \ + && rm /tmp/requirements.txt \ + && groupadd --system --gid 10001 sealed \ + && useradd --system --uid 10001 --gid 10001 --home-dir /app --shell /sbin/nologin sealed \ + && chown -R sealed:sealed /app \ + && chmod 0555 /app /app/templates /usr/local/bin/get-snp-report \ + && chmod 0444 /app/app.py /app/entrypoint.py /app/sealed-data.enc /app/firewall-policy.json /usr/local/bin/get-snp-report.sha256 \ + && find / -xdev -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true + +# Verify the SNP report binary checksum at image build time so a tampered +# build cache would explode the image. The sha256 manifest was generated +# in stage 1 against the basename only, so verification works from +# /usr/local/bin where the binary now lives. +RUN cd /usr/local/bin && sha256sum -c get-snp-report.sha256 + +USER sealed:sealed +WORKDIR /app +EXPOSE 8443 + +# PID 1 = entrypoint — performs SKR + unseal, then execs the Flask app. +ENTRYPOINT ["python", "/app/entrypoint.py"] diff --git a/aci-samples/sealed-container/README.md b/aci-samples/sealed-container/README.md new file mode 100644 index 0000000..2201268 --- /dev/null +++ b/aci-samples/sealed-container/README.md @@ -0,0 +1,184 @@ +# sealed-container — sealed, attested, signed sample for ACI Confidential Containers + +A minimal but production-shaped example of an [Azure Container Instances +Confidential Container][aci-cc] group with **every defensive lever pulled**: + +[aci-cc]: https://learn.microsoft.com/azure/container-instances/container-instances-confidential-overview + +| Lever | How it's enforced here | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Confidential Computing Enforcement (CCE) policy | Generated by `az confcom acipolicygen` against [deployment-template.json](deployment-template.json) at build time. The platform refuses hand-authored CCE policies, so this is the only form the runtime will load. | +| Signed container image, single pinned digest | [Dockerfile](Dockerfile) builds with a pinned base digest; ACR push records an immutable `sha256` reference and the CCE policy is bound to that exact digest + layer hashes. | +| Encrypted filesystem inside the container | [entrypoint.py](entrypoint.py) performs Secure Key Release and unwraps `artifacts/sealed-data.enc` into a tmpfs at `/run/sealed` using AES-256-GCM keyed by an RSA-HSM key. | +| SKR release policy bound to the CCE policy | [policies/skr-release-policy.json](policies/skr-release-policy.json) pins `x-ms-sevsnpvm-hostdata` to SHA-256(cce-policy) so the AES key only releases under THIS policy. | +| Reproducible SBOM with package versions | [artifacts/sealed-app.sbom.spdx.json](artifacts/sealed-app.sbom.spdx.json) + cyclonedx, regenerated by `Build-SealedArtifacts.ps1` via `syft`. | +| Signed vulnerability scan | [artifacts/trivy-report.json](artifacts/trivy-report.json) + `.sig`, regenerated via `trivy` and signed with cosign. | +| Signed policy definitions | Every file under `artifacts/` ships with a detached `.sig` and is listed in `artifacts/MANIFEST.json` (itself signed). | +| Codified, signed L7 firewall (default-deny) | [policies/firewall-policy.json](policies/firewall-policy.json) is the source of truth; the rendered file is baked into the container image. The CCE policy pins its SHA-256 via the `FIREWALL_POLICY_SHA256` env var, so any tamper between build and run causes the app to refuse to start. The app enforces the policy in a Flask `before_request` hook — only the trusted source CIDR can reach any route; everything else gets a 403. | +| Checksums verified before every deploy | `Deploy-SealedContainer.ps1 -Deploy` re-runs `-Verify` first and refuses to deploy on drift. | + +## Why the firewall is at L7, not L4 + +Confidential ACI does **not** support attaching a container group to a +VNet/NSG without also adding a NAT Gateway for outbound traffic +([docs][vnet-docs]). That extra resource adds cost, complexity, and a +second public IP that doesn't fit a single-file sample. So this sample +publishes the container on a **public IP** and enforces the same +default-deny ingress rule at the application layer instead. The policy +file is still the source of truth, still signed, and its SHA-256 is +still cryptographically bound to the CCE policy — so a tampered policy +fails the SKR release, exactly the same way a tampered NSG rule chain +would have if the platform allowed it. + +[vnet-docs]: https://learn.microsoft.com/azure/container-instances/container-instances-virtual-network-concepts + +## Layout + +``` +sealed-container/ +├── README.md ← you are here +├── app.py ← Flask attestation + sealed-status web app +├── entrypoint.py ← PID 1: SKR + unseal, then exec the app +├── Dockerfile ← restrictive runtime image, bakes firewall-policy.json +├── requirements.txt +├── templates/ +│ └── index.html ← single-page visual attestation UI +├── policies/ +│ ├── skr-release-policy.json ← AKV release policy (binds to CCE SHA-256) +│ ├── firewall-policy.json ← declarative L7 firewall (source of truth) +│ └── README.md +├── deployment-template.json ← ARM template (Confidential SKU, public IP) +├── Build-SealedArtifacts.ps1 ← build + SBOM + scan + sign + checksum + confcom CCE policy +├── Deploy-SealedContainer.ps1 ← verify + deploy + cleanup +├── artifacts/ ← signed bundle (generated by Build-SealedArtifacts.ps1) +│ ├── README.md +│ ├── MANIFEST.json (+ .sig) +│ ├── checksums.sha256 +│ ├── cce-policy.rego (+ .sig) ← generated by az confcom acipolicygen +│ ├── skr-release-policy.json (+ .sig) +│ ├── firewall-policy.json (+ .sig) +│ ├── sealed-data.enc (+ .sig) +│ ├── wrap-key.public.pem +│ ├── sealed-app.sbom.spdx.json (+ .sig) +│ ├── sealed-app.sbom.cyclonedx.json (+ .sig) +│ ├── trivy-report.json (+ .sig) +│ ├── trivy-report.summary.md (+ .sig) +│ └── deployment-template.json.sig +└── .gitignore +``` + +## End-to-end flow + +```mermaid +flowchart LR + A[Build-SealedArtifacts.ps1] --> B[az acr build] + A --> C[Generate sealed-data.enc
AES-256-GCM + RSA-4096 wrap] + A --> D[syft SBOM + trivy scan] + A --> E[cosign sign every artifact] + A --> R[Render firewall-policy.json
__TRUSTED_SOURCE_CIDR__ substituted] + R --> B + B --> F[az confcom acipolicygen
against deployment-template.json] + F --> G[Render skr-release-policy.json
binds sha256 of CCE policy] + G --> H[Import wrap key to AKV
with release policy attached] + H --> I[Deploy-SealedContainer.ps1] + I --> J[ACI Confidential Container
public IP, L7 firewall in app] + J --> K[entrypoint.py:
SEV-SNP report --> MAA --> AKV release] + K --> L[Decrypt sealed-data.enc
into /run/sealed tmpfs] + L --> M[Flask app serves
attestation + sealed status UI] +``` + +The cryptographic chain that makes this sealed in the strict sense: + +1. The ACI control plane sets the SEV-SNP `HOST_DATA` field to + `sha256(cce-policy)` at `SNP_LAUNCH_FINISH`. +2. The entrypoint asks the AMD Secure Processor for an SNP report and + posts it to MAA; MAA surfaces `HOST_DATA` as the + `x-ms-sevsnpvm-hostdata` claim in the signed JWT. +3. The SKR release policy on the AKV wrapping key requires + `x-ms-sevsnpvm-hostdata == ` exactly. **Edit one + byte of the policy → hash changes → AKV refuses to release the key → + the container crash-loops with no plaintext data ever in memory.** + +The CCE policy in turn pins: + +- The exact image digest + layer hashes (so a re-pushed `latest` is rejected). +- The exact command, working directory, uid/gid (so the entrypoint can't be replaced). +- Every environment variable byte-for-byte, including `TRUSTED_SOURCE_CIDR` + and `FIREWALL_POLICY_SHA256` — so the app's L7 firewall can't be bypassed + by re-deploying the same image with different env values. + +## Quick start + +```powershell +# One-time: build, scan, SBOM, sign, push image, create RG/ACR/KV/identity, +# generate CCE policy with az confcom acipolicygen. +cd aci-samples/sealed-container +./Build-SealedArtifacts.ps1 -Build # uses prefix sgall by default + +# Deploy (re-verifies checksums first; refuses to deploy on drift). +./Deploy-SealedContainer.ps1 -Deploy + +# Lock the firewall to a different CIDR (rebuild required — the CIDR is +# baked into the CCE policy via the env var): +./Build-SealedArtifacts.ps1 -Build -TrustedSourceCidr 203.0.113.0/28 +./Deploy-SealedContainer.ps1 -Deploy + +# Prove that az container exec is blocked: +$cfg = Get-Content acr-config.json | ConvertFrom-Json +$cg = az container list -g $cfg.resourceGroup --query "[0].name" -o tsv +az container exec -g $cfg.resourceGroup -n $cg --exec-command /bin/sh +# ↑ The ACI control plane will refuse the call; the CCE policy has no exec_processes entries. + +# Tear down just the container group: +./Deploy-SealedContainer.ps1 -Cleanup + +# Verify the bundle without deploying: +./Deploy-SealedContainer.ps1 -Verify +``` + +## Prerequisites + +Required: + +- Azure subscription with quota for Confidential ACI in your chosen region. +- Azure CLI (`az login`) with the **`confcom` extension** installed + (`az extension add --name confcom`). +- PowerShell 7+ (uses `System.Security.Cryptography.AesGcm`). + +Optional but strongly recommended (the build script falls back to clearly +labeled placeholders if any of these are missing): + +| Tool | Used for | +| --------------------------------------------------------------------- | --------------------------------------------------------- | +| [syft](https://github.com/anchore/syft) | Generating the SPDX + CycloneDX SBOMs. | +| [trivy](https://aquasecurity.github.io/trivy/) | Container image vulnerability scan. | +| [cosign](https://docs.sigstore.dev/cosign/installation/) | Signing every artifact + the top-level manifest. | + +## What is intentionally NOT here + +- **No NSG / VNet integration.** Confidential ACI requires a NAT Gateway + for outbound traffic in VNet mode; this sample chooses simplicity over + the extra resource and enforces the same default-deny ingress rule + inside the app instead. See *Why the firewall is at L7* above. +- **No Standard-SKU deployment.** The SKR release policy refuses to release + the AES wrapping key off SEV-SNP, so a Standard deploy would crash-loop + forever and be more confusing than educational. See + [aci-samples/visual-attestation-demo-v2/](../visual-attestation-demo-v2/) + for a side-by-side Confidential/Standard comparison without SKR. +- **No hand-authored CCE policy.** The ACI control plane silently rejects + any CCE policy not produced by `az confcom acipolicygen` — the + deployment provisions but the container never starts, and ARM hits + its 30-minute timeout with zero events. We use confcom and document + the resulting policy under `artifacts/cce-policy.rego`. +- **No interactive endpoints.** No `/exec`, `/eval`, `/debug` routes; no + templating of user input; no upload route. + +## Related samples in this repo + +- [`visual-attestation-demo-v2/`](../visual-attestation-demo-v2/) — + attestation visualization without SKR, with a side-by-side + Confidential/Standard comparison. +- [`skr-examples/Deploy-SKRExample.ps1`](../../skr-examples/Deploy-SKRExample.ps1) — + SKR end-to-end on a Confidential VM (not a container). +- [`app-and-postgreSQL-demo/`](../app-and-postgreSQL-demo/) — multi-container + confidential ACI with PostgreSQL. diff --git a/aci-samples/sealed-container/app.py b/aci-samples/sealed-container/app.py new file mode 100644 index 0000000..b89c625 --- /dev/null +++ b/aci-samples/sealed-container/app.py @@ -0,0 +1,436 @@ +""" +sealed-app — runtime attestation web UI for an ACI Confidential Container +group that is locked down with a CCE policy produced by `az confcom +acipolicygen` and an encrypted "sealed" data bundle that is only unwrapped +after Secure Key Release (SKR) succeeds inside the TEE. + +The container has no interactive access: + * the CCE policy has no exec_processes entries, so `az container exec` is + rejected by the ACI control plane; the image runs as a non-root user + with a read-only root filesystem; + * the only writable surface is an in-memory tmpfs at /run/sealed where the + SKR-released AES-256-GCM key decrypts the sealed bundle that ships + alongside the image (artifacts/sealed-data.enc); + * ingress is restricted at L7 by enforce_firewall() below to the trusted + source CIDR baked into the CCE policy at build time. + +The web app is single-purpose: + * GET / — visual attestation page (Jinja template, no JS deps). + * POST /api/attest — fetch a fresh SEV-SNP report via get-snp-report, + post it to MAA, and return the JWT claims. + * GET /api/sealed — show the unsealed metadata that proves SKR worked. + * GET /healthz — kubelet/probe liveness (returns 200 OK only). + +There is intentionally no /exec, /shell, /eval, /debug, /metrics-with-labels +endpoint, no template rendering of user input, and no upload route. +""" + +from __future__ import annotations + +import base64 +import hashlib +import ipaddress +import json +import os +import secrets +import struct +import subprocess +import traceback +from datetime import datetime, timezone +from pathlib import Path + +import requests +from flask import Flask, abort, jsonify, render_template, request + + +# --------------------------------------------------------------------------- +# Configuration (all overridable via env, but every variable is pinned in the +# CCE policy with strategy="string" so an attacker cannot inject new values). +# --------------------------------------------------------------------------- +GET_SNP_REPORT = os.environ.get("GET_SNP_REPORT", "/usr/local/bin/get-snp-report") +MAA_ENDPOINT = os.environ.get("MAA_ENDPOINT", "sharedeus.eus.attest.azure.net") +MAA_API = os.environ.get("MAA_API_VERSION", "2022-08-01") +SEALED_DIR = Path(os.environ.get("SEALED_DIR", "/run/sealed")) +FIREWALL_POLICY_PATH = Path(os.environ.get("FIREWALL_POLICY", "/app/firewall-policy.json")) +FIREWALL_POLICY_SHA256 = (os.environ.get("FIREWALL_POLICY_SHA256") or "").lower() +TRUSTED_SOURCE_CIDR = os.environ.get("TRUSTED_SOURCE_CIDR", "0.0.0.0/0") +APP_NAME = "sealed-app" +APP_VERSION = "1.0.0" + +# Routes that are reachable from any source IP. /healthz is needed so the +# ACI platform's TCP probe can verify liveness without us knowing its +# source CIDR. +FIREWALL_BYPASS_PATHS = frozenset({"/healthz"}) + +# Bytes/offsets for the AMD SEV-SNP attestation report layout (see AMD SEV-SNP +# ABI specification, publication 56860, table "ATTESTATION_REPORT structure"). +REPORT_LEN = 0x4A0 # 1184 bytes + + +# --------------------------------------------------------------------------- +# MAA claim explanations (re-used from the visual-attestation-demo-v2 sample). +# --------------------------------------------------------------------------- +CLAIM_EXPLANATIONS = { + "iss": "Issuer URL of the MAA endpoint that signed this token.", + "iat": "Issued At (Unix epoch seconds).", + "exp": "Expiration (Unix epoch seconds).", + "nbf": "Not Before (Unix epoch seconds).", + "jti": "JWT ID — unique per token, useful for replay detection.", + "x-ms-ver": "MAA token schema version.", + "x-ms-attestation-type": "TEE type ('sevsnpvm' on ACI Confidential).", + "x-ms-compliance-status": "MAA verdict ('azure-compliant-uvm' on success).", + "x-ms-policy-hash": "SHA-256 of the MAA policy that evaluated this evidence.", + "x-ms-runtime": "Caller-supplied runtime data bound into REPORT_DATA.", + "x-ms-inittime": "Init-time data bound into the report (CCE policy hash on ACI CC).", + "x-ms-sevsnpvm-hostdata": "Host data the hypervisor injected at launch — on ACI CC this is the SHA-256 of the CCE policy. Pin this in the SKR release policy.", + "x-ms-sevsnpvm-is-debuggable": "Must be false for production CVMs.", + "x-ms-sevsnpvm-launchmeasurement": "SHA-384 of the initial guest memory contents — the cryptographic identity of the boot image.", + "x-ms-sevsnpvm-reportdata": "Hex of REPORT_DATA the guest supplied; MAA sets this to SHA-256(x-ms-runtime).", + "x-ms-sevsnpvm-vmpl": "Virtual Machine Privilege Level (Azure CVMs report VMPL0).", +} + + +def _explain(key: str) -> str: + if key in CLAIM_EXPLANATIONS: + return CLAIM_EXPLANATIONS[key] + if key.startswith("x-ms-sevsnpvm-"): + return "SEV-SNP attestation report field surfaced by MAA. See AMD SEV-SNP ABI spec." + if key.startswith("x-ms-"): + return "MAA-issued claim. Refer to Microsoft Azure Attestation documentation." + return "Standard JWT or caller-supplied claim." + + +# --------------------------------------------------------------------------- +# JWT helpers (display-only; MAA does the cryptographic verification). +# --------------------------------------------------------------------------- +def _b64url_decode(segment: str) -> bytes: + pad = "=" * (-len(segment) % 4) + return base64.urlsafe_b64decode(segment + pad) + + +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode("ascii") + + +def decode_jwt(token: str): + if isinstance(token, bytes): + token = token.decode("utf-8") + parts = token.split(".") + if len(parts) != 3: + raise ValueError(f"Token does not have 3 segments (got {len(parts)})") + return json.loads(_b64url_decode(parts[0])), json.loads(_b64url_decode(parts[1])) + + +def _format_timestamp(value): + try: + return datetime.fromtimestamp(int(value), tz=timezone.utc).isoformat() + except Exception: + return None + + +def annotate_claims(payload: dict): + rows = [] + for key, value in payload.items(): + row = {"key": key, "value": value, "explanation": _explain(key)} + if key in {"iat", "exp", "nbf"}: + row["timestamp"] = _format_timestamp(value) + rows.append(row) + rows.sort(key=lambda r: (not r["key"].startswith("x-ms-"), r["key"])) + return rows + + +# --------------------------------------------------------------------------- +# UVM information injected by the ACI control plane. +# --------------------------------------------------------------------------- +def _find_security_context_dir() -> str | None: + explicit = os.environ.get("UVM_SECURITY_CONTEXT_DIR") + if explicit and os.path.isdir(explicit): + return explicit + try: + for entry in os.listdir("/"): + if entry.startswith("security-context-"): + full = os.path.join("/", entry) + if os.path.isdir(full): + return full + except OSError: + pass + return None + + +def load_uvm_information() -> dict: + ctx_dir = _find_security_context_dir() + if ctx_dir: + def _read(name): + p = Path(ctx_dir) / name + return p.read_text().strip() if p.exists() else "" + return { + "host_amd_cert_b64": _read("host-amd-cert-base64"), + "reference_info_b64": _read("reference-info-base64"), + "source": ctx_dir, + } + return { + "host_amd_cert_b64": os.environ.get("UVM_HOST_AMD_CERTIFICATE", ""), + "reference_info_b64": os.environ.get("UVM_REFERENCE_INFO", ""), + "source": "env", + } + + +def _build_maa_report(snp_report: bytes, vcek_cert_chain: bytes, endorsements_json: bytes | None) -> str: + inner = { + "SnpReport": _b64url(snp_report), + "VcekCertChain": _b64url(vcek_cert_chain), + } + if endorsements_json: + inner["Endorsements"] = _b64url(endorsements_json) + return _b64url(json.dumps(inner).encode("utf-8")) + + +# --------------------------------------------------------------------------- +# get-snp-report invocation. +# --------------------------------------------------------------------------- +def fetch_snp_report(report_data: bytes) -> bytes: + if not (os.path.exists("/dev/sev-guest") or os.path.exists("/dev/sev")): + raise RuntimeError( + "Neither /dev/sev-guest nor /dev/sev is present. This container " + "must run on ACI Confidential SKU (AMD SEV-SNP)." + ) + proc = subprocess.run( + [GET_SNP_REPORT, report_data.hex()], + capture_output=True, + timeout=15, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError( + f"get-snp-report exited {proc.returncode}. " + f"stdout: {proc.stdout.decode('utf-8', 'replace')[:500]} " + f"stderr: {proc.stderr.decode('utf-8', 'replace')[:500]}" + ) + hex_output = "".join(c for c in proc.stdout.decode("ascii", "replace") if c in "0123456789abcdefABCDEF") + report = bytes.fromhex(hex_output) + if len(report) < REPORT_LEN: + raise RuntimeError(f"SNP report too short: {len(report)} bytes") + return report[:REPORT_LEN] + + +def parse_snp_report_summary(buf: bytes) -> dict: + version = struct.unpack_from(" dict: + nonce = user_nonce or secrets.token_hex(16) + runtime_obj = {"nonce": nonce, "client": APP_NAME, "version": APP_VERSION} + runtime_bytes = json.dumps(runtime_obj).encode("utf-8") + report_data = hashlib.sha256(runtime_bytes).digest() + b"\x00" * 32 + + snp_report = fetch_snp_report(report_data) + summary = parse_snp_report_summary(snp_report) + + uvm = load_uvm_information() + if not uvm["host_amd_cert_b64"]: + raise RuntimeError( + "UVM host AMD certificate not found. Expected security-context-*/host-amd-cert-base64 " + "or UVM_HOST_AMD_CERTIFICATE env var. ACI control plane injects this on Confidential SKU." + ) + thim_certs = json.loads(base64.b64decode(uvm["host_amd_cert_b64"])) + vcek_chain = (thim_certs.get("vcekCert", "") + thim_certs.get("certificateChain", "")).encode("utf-8") + + endorsements_json = None + if uvm["reference_info_b64"]: + ref_info = base64.b64decode(uvm["reference_info_b64"]) + endorsements_json = json.dumps({"Uvm": [_b64url(ref_info)]}).encode("utf-8") + + body = { + "report": _build_maa_report(snp_report, vcek_chain, endorsements_json), + "runtimeData": {"data": _b64url(runtime_bytes), "dataType": "JSON"}, + "nonce": secrets.randbits(63), + } + url = f"https://{MAA_ENDPOINT}/attest/SevSnpVm?api-version={MAA_API}" + resp = requests.post(url, json=body, timeout=30, headers={"User-Agent": APP_NAME}) + if resp.status_code != 200: + raise RuntimeError(f"MAA POST {url} returned HTTP {resp.status_code}: {resp.text[:600]}") + token = resp.json().get("token") + if not token: + raise RuntimeError(f"MAA response missing 'token': {resp.text[:600]}") + + header, payload = decode_jwt(token) + + return { + "endpoint": f"https://{MAA_ENDPOINT}", + "region": MAA_ENDPOINT.split(".")[0], + "isolation_type": "SEV_SNP", + "nonce": nonce, + "token": token, + "header": header, + "payload": payload, + "claims": annotate_claims(payload), + "hardware_evidence": { + "maa_endpoint": MAA_ENDPOINT, + "uvm_source": uvm["source"], + "snp_report_size": len(snp_report), + "snp_report_hex": snp_report.hex(), + "snp_summary": summary, + "runtime_data": runtime_obj, + "runtime_data_sha256": hashlib.sha256(runtime_bytes).hexdigest(), + }, + } + + +# --------------------------------------------------------------------------- +# Sealed bundle status — the entrypoint writes /run/sealed/manifest.json after +# SKR succeeds. We never expose the decrypted content itself, only metadata. +# --------------------------------------------------------------------------- +def sealed_status() -> dict: + manifest = SEALED_DIR / "manifest.json" + if not manifest.is_file(): + return { + "unsealed": False, + "reason": "Sealed bundle manifest not found. SKR may have failed or the " + "image was started outside of an ACI Confidential container group.", + } + info = json.loads(manifest.read_text()) + decrypted_files = [] + try: + for p in sorted(SEALED_DIR.iterdir()): + if p.is_file() and p.name != "manifest.json": + decrypted_files.append({ + "name": p.name, + "size": p.stat().st_size, + "sha256": hashlib.sha256(p.read_bytes()).hexdigest(), + }) + except Exception as exc: + decrypted_files = [{"error": str(exc)}] + return { + "unsealed": True, + "sealed_at": info.get("sealed_at"), + "unsealed_at": info.get("unsealed_at"), + "akv_endpoint": info.get("akv_endpoint"), + "key_name": info.get("key_name"), + "key_version": info.get("key_version"), + "ciphertext_sha256": info.get("ciphertext_sha256"), + "plaintext_sha256": info.get("plaintext_sha256"), + "wrap_algorithm": info.get("wrap_algorithm"), + "release_policy_sha256": info.get("release_policy_sha256"), + "files": decrypted_files, + } + + +# --------------------------------------------------------------------------- +# Firewall (L7) — Confidential ACI does not support NSGs without a NAT +# gateway, so the signed artifacts/firewall-policy.json is enforced INSIDE +# the container instead. The policy file's SHA-256 is bound by the CCE +# policy (env var FIREWALL_POLICY_SHA256, baked at policy-gen time), so a +# tampered policy is detected at process start and the container exits. +# --------------------------------------------------------------------------- +def load_and_verify_firewall_policy() -> dict: + if not FIREWALL_POLICY_PATH.is_file(): + raise RuntimeError(f"Firewall policy not found at {FIREWALL_POLICY_PATH}") + raw = FIREWALL_POLICY_PATH.read_bytes() + actual = hashlib.sha256(raw).hexdigest() + if FIREWALL_POLICY_SHA256 and actual != FIREWALL_POLICY_SHA256: + raise RuntimeError( + f"Firewall policy SHA-256 mismatch: expected {FIREWALL_POLICY_SHA256}, got {actual}. " + "Refusing to start." + ) + return json.loads(raw) + + +try: + FIREWALL_POLICY = load_and_verify_firewall_policy() + _trusted_net = ipaddress.ip_network(TRUSTED_SOURCE_CIDR, strict=False) +except Exception as exc: + # Surface the failure to stdout and re-raise so the container exits. + print(f"[sealed-app] FATAL: {exc}", flush=True) + raise + + +def _client_ip() -> str: + # ACI's public IP routes directly to the container; there is no proxy + # in front. Trust request.remote_addr only. + return request.remote_addr or "" + + +# --------------------------------------------------------------------------- +# Flask app +# --------------------------------------------------------------------------- +app = Flask(__name__) + + +@app.before_request +def enforce_firewall(): + if request.path in FIREWALL_BYPASS_PATHS: + return None + ip = _client_ip() + try: + addr = ipaddress.ip_address(ip) + except ValueError: + abort(403) + if addr not in _trusted_net: + abort(403) + return None + + +@app.route("/", methods=["GET"]) +def index(): + return render_template( + "index.html", + app_name=APP_NAME, + app_version=APP_VERSION, + maa_endpoint=MAA_ENDPOINT, + ) + + +@app.route("/healthz", methods=["GET"]) +def healthz(): + # No body, no metadata — just liveness. + return "ok", 200 + + +@app.route("/api/firewall", methods=["GET"]) +def api_firewall(): + return jsonify({ + "ok": True, + "policy_sha256": FIREWALL_POLICY_SHA256, + "trusted_source_cidr": TRUSTED_SOURCE_CIDR, + "policy": FIREWALL_POLICY, + }) + + +@app.route("/api/attest", methods=["POST"]) +def api_attest(): + user_nonce = (request.json or {}).get("nonce") if request.is_json else request.form.get("nonce") + try: + result = perform_attestation(user_nonce or None) + except Exception as exc: + return ( + jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}), + 500, + ) + return jsonify({"ok": True, **result}) + + +@app.route("/api/sealed", methods=["GET"]) +def api_sealed(): + return jsonify({"ok": True, **sealed_status()}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8443"))) diff --git a/aci-samples/sealed-container/deployment-template.json b/aci-samples/sealed-container/deployment-template.json new file mode 100644 index 0000000..445024b --- /dev/null +++ b/aci-samples/sealed-container/deployment-template.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "description": "Sealed-container ACI deployment. Confidential SKU only — the SKR release policy refuses to release the wrapping key off-hardware, so a Standard deploy would crash-loop forever. The container is exposed on a PUBLIC IP because Confidential ACI does not support VNet integration without a NAT gateway, and the wider sealed-container sample chooses simplicity over a multi-resource networking stack. Ingress is restricted at L7 by the app (see app.py before_request hook), driven by the signed artifacts/firewall-policy.json that the build stamped with the same sha256 recorded here as firewallPolicySha256." + }, + "parameters": { + "containerGroupName": { "type": "string" }, + "location": { "type": "string", "defaultValue": "[resourceGroup().location]" }, + "dnsNameLabel": { "type": "string" }, + "appImage": { "type": "string", "metadata": { "description": "Fully qualified image with digest, e.g. acrxxxx.azurecr.io/sealed-app@sha256:..." } }, + "registryServer": { "type": "string" }, + "registryUsername": { "type": "string" }, + "registryPassword": { "type": "securestring" }, + "managedIdentityId": { "type": "string", "metadata": { "description": "Resource ID of the user-assigned managed identity that has 'get'+'release' on the SKR key." } }, + "maaEndpoint": { "type": "string" }, + "akvEndpoint": { "type": "string" }, + "skrKeyName": { "type": "string" }, + "releasePolicySha256": { "type": "string", "metadata": { "description": "SHA-256 (hex) of the SKR release policy bytes." } }, + "firewallPolicySha256": { "type": "string", "metadata": { "description": "SHA-256 (hex) of artifacts/firewall-policy.json. Passed to the container as FIREWALL_POLICY_SHA256 so the app can refuse to start if the on-disk file was tampered with. Also stamped on the container group as a tag for audit." } }, + "trustedSourceCidr": { "type": "string", "metadata": { "description": "CIDR block allowed to call the app at L7 (every route except /healthz). Enforced inside the Python app, not at the platform — Confidential ACI cannot use NSGs without a NAT gateway." } }, + "ccePolicyBase64": { "type": "string", "metadata": { "description": "Base64 of the az confcom acipolicygen output (Rego)." } } + }, + "resources": [ + { + "type": "Microsoft.ContainerInstance/containerGroups", + "apiVersion": "2023-05-01", + "name": "[parameters('containerGroupName')]", + "location": "[parameters('location')]", + "tags": { + "sample": "sealed-container", + "cce_policy_sha256": "[parameters('releasePolicySha256')]", + "firewall_policy_sha256": "[parameters('firewallPolicySha256')]" + }, + "identity": { + "type": "UserAssigned", + "userAssignedIdentities": { + "[parameters('managedIdentityId')]": {} + } + }, + "properties": { + "sku": "Confidential", + "confidentialComputeProperties": { + "ccePolicy": "[parameters('ccePolicyBase64')]" + }, + "containers": [ + { + "name": "sealed-app", + "properties": { + "image": "[parameters('appImage')]", + "command": [ "python", "/app/entrypoint.py" ], + "ports": [ + { "protocol": "TCP", "port": 8443 } + ], + "environmentVariables": [ + { "name": "PATH", "value": "/usr/local/bin:/usr/bin:/bin" }, + { "name": "PYTHONDONTWRITEBYTECODE", "value": "1" }, + { "name": "PYTHONUNBUFFERED", "value": "1" }, + { "name": "PORT", "value": "8443" }, + { "name": "SEALED_DIR", "value": "/run/sealed" }, + { "name": "SEALED_BUNDLE", "value": "/app/sealed-data.enc" }, + { "name": "GET_SNP_REPORT", "value": "/usr/local/bin/get-snp-report" }, + { "name": "MAA_ENDPOINT", "value": "[parameters('maaEndpoint')]" }, + { "name": "AKV_ENDPOINT", "value": "[parameters('akvEndpoint')]" }, + { "name": "SKR_KEY_NAME", "value": "[parameters('skrKeyName')]" }, + { "name": "FIREWALL_POLICY", "value": "/app/firewall-policy.json" }, + { "name": "FIREWALL_POLICY_SHA256", "value": "[parameters('firewallPolicySha256')]" }, + { "name": "TRUSTED_SOURCE_CIDR", "value": "[parameters('trustedSourceCidr')]" } + ], + "resources": { + "requests": { + "memoryInGB": 2, + "cpu": 1 + } + }, + "volumeMounts": [ + { "name": "sealed-tmpfs", "mountPath": "/run/sealed" } + ] + } + } + ], + "volumes": [ + { "name": "sealed-tmpfs", "emptyDir": {} } + ], + "imageRegistryCredentials": [ + { + "server": "[parameters('registryServer')]", + "username": "[parameters('registryUsername')]", + "password": "[parameters('registryPassword')]" + } + ], + "ipAddress": { + "type": "Public", + "dnsNameLabel": "[parameters('dnsNameLabel')]", + "ports": [ + { "protocol": "TCP", "port": 8443 } + ] + }, + "osType": "Linux", + "restartPolicy": "Always" + } + } + ], + "outputs": { + "publicIp": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupName'))).ipAddress.ip]" + }, + "fqdn": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupName'))).ipAddress.fqdn]" + }, + "url": { + "type": "string", + "value": "[concat('http://', reference(resourceId('Microsoft.ContainerInstance/containerGroups', parameters('containerGroupName'))).ipAddress.fqdn, ':8443')]" + } + } +} diff --git a/aci-samples/sealed-container/deployment-template.json.sig b/aci-samples/sealed-container/deployment-template.json.sig new file mode 100644 index 0000000..68d1ec5 --- /dev/null +++ b/aci-samples/sealed-container/deployment-template.json.sig @@ -0,0 +1,8 @@ +{ + "algorithm": "sha256", + "file": "deployment-template.json", + "note": "cosign is not installed — install it and re-run -Refresh for a real signature.", + "signed_at": "2026-06-12T18:32:47Z", + "digest": "d9dab11237bd83dbbf26b788ce8ace8dcab4c6537c90a2929353095f65ff3692", + "_placeholder": true +} diff --git a/aci-samples/sealed-container/entrypoint.py b/aci-samples/sealed-container/entrypoint.py new file mode 100644 index 0000000..d9623e7 --- /dev/null +++ b/aci-samples/sealed-container/entrypoint.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +""" +sealed-app entrypoint. + +This script is PID 1 inside the container. The CCE policy denies +`exec_in_container`, so this is the ONLY process tree the container +ever runs. The script performs three steps before handing off to the +Flask app: + + 1. Fetch a fresh MAA SEV-SNP attestation token using the get-snp-report + binary baked into the image and the THIM cert chain + UVM endorsements + dropped by the ACI control plane into the security-context-* mount. + + 2. Call Azure Key Vault Secure Key Release (SKR) for the AES wrapping key. + The key's release policy (see policies/skr-release-policy.json) binds: + - x-ms-isolation-tee.x-ms-attestation-type == "sevsnpvm" + - x-ms-isolation-tee.x-ms-compliance-status == "azure-compliant-uvm" + - x-ms-sevsnpvm-hostdata == + - x-ms-sevsnpvm-is-debuggable == false + so the key only releases inside this exact container, on AMD SEV-SNP, + when the CCE policy hash matches. + + 3. Use the released RSA-HSM key (returned as a JWK inside a JWS) to + unwrap the AES-256-GCM data-encryption key, then decrypt + /app/sealed-data.enc into /run/sealed/. /run/sealed is a + tmpfs mounted by the ACI runtime — it is never persisted, never + accessible from outside the TEE. + +A manifest is written to /run/sealed/manifest.json so the app can prove +in the UI that SKR worked without ever exposing the plaintext bytes. + +On any failure the container exits non-zero and the ACI runtime restarts +it. There is no fallback path that runs the app with no key — that is +the whole point. +""" +from __future__ import annotations + +import sys +sys.stdout.write("[entrypoint] python interpreter alive\n"); sys.stdout.flush() + +import base64 +import hashlib +import json +import os +import struct +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path + +sys.stdout.write("[entrypoint] stdlib imports ok\n"); sys.stdout.flush() + +import requests +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.keywrap import aes_key_unwrap, aes_key_unwrap_with_padding + +sys.stdout.write("[entrypoint] third-party imports ok\n"); sys.stdout.flush() + + +GET_SNP_REPORT = os.environ.get("GET_SNP_REPORT", "/usr/local/bin/get-snp-report") +MAA_ENDPOINT = os.environ["MAA_ENDPOINT"] # required, pinned in CCE policy +AKV_ENDPOINT = os.environ["AKV_ENDPOINT"] # required, pinned in CCE policy +KEY_NAME = os.environ["SKR_KEY_NAME"] # required, pinned in CCE policy +SEALED_BUNDLE = Path(os.environ.get("SEALED_BUNDLE", "/app/sealed-data.enc")) +SEALED_DIR = Path(os.environ.get("SEALED_DIR", "/run/sealed")) +APP_CMD = ["python", "/app/app.py"] + +REPORT_LEN = 0x4A0 + + +def log(msg: str) -> None: + """The CCE policy denies runtime logging back to the ACI control plane, + but writing to stdout/stderr inside the container is harmless — those + streams are dropped at the boundary.""" + sys.stdout.write(f"[entrypoint] {msg}\n") + sys.stdout.flush() + + +# --------------------------------------------------------------------------- +# MAA attestation +# --------------------------------------------------------------------------- +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode("ascii") + + +def _b64url_decode(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def _int_to_b64url(i: int) -> str: + return _b64url(i.to_bytes((i.bit_length() + 7) // 8, "big")) + + +# Ephemeral RSA wrap key. AKV /release wraps the HSM-released key with this +# key using CKM_RSA_AES_KEY_WRAP, so it never leaves the TEE in the clear. +# Lazy so any failure surfaces *after* main() has logged. +_EPHEMERAL_PRIV = None +EPHEMERAL_JWK_PUB = None + + +def _ensure_ephemeral_key(): + global _EPHEMERAL_PRIV, EPHEMERAL_JWK_PUB + if _EPHEMERAL_PRIV is not None: + return + _EPHEMERAL_PRIV = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pub = _EPHEMERAL_PRIV.public_key().public_numbers() + EPHEMERAL_JWK_PUB = { + "kty": "RSA", + "kid": "sealed-app-ephemeral-wrap", + "key_ops": ["wrapKey", "encrypt"], + "alg": "RSA-OAEP-256", + "n": _int_to_b64url(pub.n), + "e": _int_to_b64url(pub.e), + } + + +def fetch_snp_report(report_data: bytes) -> bytes: + proc = subprocess.run([GET_SNP_REPORT, report_data.hex()], + capture_output=True, timeout=15, check=False) + if proc.returncode != 0: + raise RuntimeError(f"get-snp-report failed: {proc.stderr!r}") + hex_out = "".join(c for c in proc.stdout.decode("ascii", "replace") if c in "0123456789abcdefABCDEF") + return bytes.fromhex(hex_out)[:REPORT_LEN] + + +def load_uvm() -> dict: + explicit = os.environ.get("UVM_SECURITY_CONTEXT_DIR") + ctx = explicit if (explicit and os.path.isdir(explicit)) else None + if not ctx: + for entry in os.listdir("/"): + if entry.startswith("security-context-") and os.path.isdir("/" + entry): + ctx = "/" + entry + break + if not ctx: + raise RuntimeError("UVM_SECURITY_CONTEXT_DIR not found") + return { + "host_amd_cert_b64": (Path(ctx) / "host-amd-cert-base64").read_text().strip(), + "reference_info_b64": (Path(ctx) / "reference-info-base64").read_text().strip(), + } + + +def get_maa_token() -> str: + # The 'keys' claim is REQUIRED for AKV /release. MAA copies runtime data + # into the issued JWT under x-ms-runtime.keys, and AKV uses one of those + # public keys as the RSA half of CKM_RSA_AES_KEY_WRAP. + _ensure_ephemeral_key() + runtime = { + "client": "sealed-app-entrypoint", + "ts": int(time.time()), + "keys": [EPHEMERAL_JWK_PUB], + } + runtime_bytes = json.dumps(runtime).encode("utf-8") + report_data = hashlib.sha256(runtime_bytes).digest() + b"\x00" * 32 + + report = fetch_snp_report(report_data) + uvm = load_uvm() + thim = json.loads(base64.b64decode(uvm["host_amd_cert_b64"])) + vcek_chain = (thim.get("vcekCert", "") + thim.get("certificateChain", "")).encode("utf-8") + endorsements = json.dumps({"Uvm": [_b64url(base64.b64decode(uvm["reference_info_b64"]))]}).encode("utf-8") + + inner = { + "SnpReport": _b64url(report), + "VcekCertChain": _b64url(vcek_chain), + "Endorsements": _b64url(endorsements), + } + body = { + "report": _b64url(json.dumps(inner).encode("utf-8")), + "runtimeData": {"data": _b64url(runtime_bytes), "dataType": "JSON"}, + } + url = f"https://{MAA_ENDPOINT}/attest/SevSnpVm?api-version=2022-08-01" + r = requests.post(url, json=body, timeout=30) + if r.status_code != 200: + raise RuntimeError(f"MAA returned HTTP {r.status_code}: {r.text[:500]}") + token = r.json().get("token") + if not token: + raise RuntimeError(f"MAA returned no token: {r.text[:500]}") + return token + + +# --------------------------------------------------------------------------- +# Managed-identity token for AKV +# --------------------------------------------------------------------------- +def get_akv_token() -> str: + """The ACI container group runs with a user-assigned managed identity that + has 'get' + 'release' on the SKR key. IMDS is reachable from inside the + TEE.""" + r = requests.get( + "http://169.254.169.254/metadata/identity/oauth2/token", + params={"api-version": "2018-02-01", "resource": "https://vault.azure.net"}, + headers={"Metadata": "true"}, + timeout=10, + ) + r.raise_for_status() + return r.json()["access_token"] + + +# --------------------------------------------------------------------------- +# SKR + unseal +# --------------------------------------------------------------------------- +def release_key(maa_token: str, akv_token: str): + """Calls AKV /keys//release. Returns (rsa_private_key, key_version).""" + akv_host = AKV_ENDPOINT.replace("https://", "").rstrip("/") + info = requests.get( + f"https://{akv_host}/keys/{KEY_NAME}?api-version=7.4", + headers={"Authorization": f"Bearer {akv_token}"}, + timeout=15, + ).json() + kid = info.get("key", {}).get("kid", "") + key_version = kid.rstrip("/").split("/")[-1] + + resp = requests.post( + f"https://{akv_host}/keys/{KEY_NAME}/{key_version}/release?api-version=7.4", + headers={"Authorization": f"Bearer {akv_token}", "Content-Type": "application/json"}, + json={"target": maa_token, "enc": "RSA_AES_KEY_WRAP_256"}, + timeout=30, + ) + if resp.status_code != 200: + raise RuntimeError(f"AKV release failed HTTP {resp.status_code}: {resp.text[:600]}") + jws = resp.json()["value"] + payload = json.loads(_b64url_decode(jws.split(".")[1])) + jwk = payload["response"]["key"]["key"] + log(f" released JWK kid={jwk.get('kid','?')} kty={jwk.get('kty','?')} fields={sorted(jwk.keys())}") + if "key_hsm" in jwk: + rsa_priv = unwrap_ckm_rsa_aes(jwk["key_hsm"]) + else: + rsa_priv = jwk_to_private_key(jwk) + return rsa_priv, key_version + + +def unwrap_ckm_rsa_aes(key_hsm_b64: str): + """CKM_RSA_AES_KEY_WRAP unwrap of the AKV /release HSM payload. + + The wrapped blob is: + [0 .. rsa_len) RSA-OAEP-SHA256(MGF1-SHA256)(AES-256 key) + [rsa_len .. end) AES Key Wrap with Padding (RFC 5649) of PKCS#8 DER + of the RSA private key + """ + blob = _b64url_decode(key_hsm_b64) + log(f" key_hsm blob: {len(blob)} bytes (rsa expects {(_EPHEMERAL_PRIV.key_size+7)//8})") + log(f" key_hsm head[0:8]={blob[:8].hex()} tail[-8:]={blob[-8:].hex()}") + log(f" key_hsm printable head: {blob[:80]!r}") + # AKV BYOK-style envelope: when key_hsm is UTF-8 JSON, unwrap that first. + if blob[:1] == b"{": + try: + env = json.loads(blob) + log(f" key_hsm envelope keys: {sorted(env.keys())}") + ct_b64 = env.get("ciphertext") or env.get("encrypted_key") or env.get("value") + if ct_b64 is None: + raise RuntimeError(f"key_hsm envelope has no ciphertext field: {list(env.keys())}") + blob = _b64url_decode(ct_b64) + log(f" key_hsm inner blob: {len(blob)} bytes") + except json.JSONDecodeError: + pass + rsa_len = (_EPHEMERAL_PRIV.key_size + 7) // 8 + wrapped_aes = blob[:rsa_len] + wrapped_key = blob[rsa_len:] + aes_key = _EPHEMERAL_PRIV.decrypt( + wrapped_aes, + padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None), + ) + log(f" aes_key: {len(aes_key)} bytes; wrapped_key: {len(wrapped_key)} bytes") + # AKV uses AES-KW (RFC 3394) for the inner wrap, not KWP. Try KW first, fall back to KWP. + try: + pkcs8_der = aes_key_unwrap(aes_key, wrapped_key) + except Exception as e: + log(f" aes_key_unwrap failed ({e}); trying aes_key_unwrap_with_padding") + pkcs8_der = aes_key_unwrap_with_padding(aes_key, wrapped_key) + log(f" unwrapped payload: {len(pkcs8_der)} bytes; head[0:4]={pkcs8_der[:4].hex()}") + return serialization.load_der_private_key(pkcs8_der, password=None) + + +def jwk_to_private_key(jwk: dict): + """Reconstruct an RSA private key from the JWK fields released by AKV.""" + def _i(name): return int.from_bytes(_b64url_decode(jwk[name]), "big") + pub = rsa.RSAPublicNumbers(_i("e"), _i("n")) + priv = rsa.RSAPrivateNumbers(_i("p"), _i("q"), _i("d"), _i("dp"), _i("dq"), _i("qi"), pub) + return priv.private_key() + + +def unseal_bundle(rsa_priv) -> dict: + """Sealed bundle format (versioned, length-prefixed): + + [ 0..3 ] magic b"SEAL" + [ 4..7 ] version uint32 LE (=1) + [ 8..11] wrap_len uint32 LE + [12..12+wl] wrapped_dek RSA-OAEP-SHA256(DEK) ; DEK is 32 random bytes + [..] nonce_len uint32 LE (=12) + [..] nonce 12-byte AES-GCM IV + [..] ct_len uint32 LE + [..] ciphertext AES-256-GCM(plaintext, aad=b"sealed-app/v1") + """ + raw = SEALED_BUNDLE.read_bytes() + ciphertext_sha256 = hashlib.sha256(raw).hexdigest() + if raw[:4] != b"SEAL": + raise RuntimeError("Sealed bundle magic mismatch") + version = struct.unpack_from(" None: + SEALED_DIR.mkdir(parents=True, exist_ok=True) + bundle = unsealed["bundle"] + files = bundle.get("files", {}) + for name, content_b64 in files.items(): + # Reject path traversal — names come from the sealed bundle but we + # treat them as untrusted just in case. + if "/" in name or name.startswith(".") or name in ("manifest.json",): + log(f"refusing suspicious filename in sealed bundle: {name!r}") + continue + (SEALED_DIR / name).write_bytes(base64.b64decode(content_b64)) + + manifest = { + "sealed_at": bundle.get("sealed_at"), + "unsealed_at": datetime.now(timezone.utc).isoformat(), + "akv_endpoint": AKV_ENDPOINT, + "key_name": KEY_NAME, + "key_version": key_version, + "ciphertext_sha256": unsealed["ciphertext_sha256"], + "plaintext_sha256": unsealed["plaintext_sha256"], + "wrap_algorithm": unsealed["wrap_algorithm"], + } + (SEALED_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2)) + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- +def main() -> int: + log(f"sealed-app starting; MAA={MAA_ENDPOINT} AKV={AKV_ENDPOINT} key={KEY_NAME}") + try: + log("Step 1/4 — fetching MAA SEV-SNP token") + maa_token = get_maa_token() + log(f" MAA token: {len(maa_token)} chars") + + log("Step 2/4 — acquiring AKV access token via IMDS") + akv_token = get_akv_token() + + log("Step 3/4 — calling AKV /release") + rsa_priv, key_version = release_key(maa_token, akv_token) + log(f" released key version: {key_version}") + + log("Step 4/4 — unsealing data bundle into tmpfs") + unsealed = unseal_bundle(rsa_priv) + write_unsealed(unsealed, key_version) + log(f" unsealed {len(unsealed['bundle'].get('files', {}))} file(s) into {SEALED_DIR}") + except Exception as exc: + log(f"FATAL: {exc}") + # Exit non-zero so the runtime restarts (or, in production, fails the + # container group). NEVER fall through to the app without sealed data. + return 2 + + log(f"Handing off to: {' '.join(APP_CMD)}") + os.execvp(APP_CMD[0], APP_CMD) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/aci-samples/sealed-container/policies/README.md b/aci-samples/sealed-container/policies/README.md new file mode 100644 index 0000000..10a5da2 --- /dev/null +++ b/aci-samples/sealed-container/policies/README.md @@ -0,0 +1,80 @@ +# Policies + +This folder contains the policy files that govern the sealed-container +demo end-to-end. They form three layered enforcement boundaries (runtime, +key-release, network) and every one of them is signed and hash-pinned into +the manifest before deploy. + +| File | Layer | What it controls | Where it ends up | +| -------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `cce-policy.rego` | UVM runtime | What the ACI confidential UVM will allow at runtime. **Generated by `az confcom acipolicygen` — not hand-edited.** | Base64-encoded and inserted into `deployment-template.json` (`confidentialComputeProperties.ccePolicy`). | +| `skr-release-policy.json` | Key release | What attestation evidence Azure Key Vault demands before releasing the wrapping key. | Base64url-encoded and stored on the AKV `release_policy` field of the RSA-HSM wrapping key. | +| `firewall-policy.json` | Application L7| What source IPs the application accepts requests from, and which ports it answers on. | The deployer's CIDR is substituted at build time and the rendered file is baked into the container image at `/app/firewall-policy.json`. | + +> `cce-policy.rego` lives in `../artifacts/` after `Build-SealedArtifacts.ps1` +> runs — not here — because the platform refuses to load anything +> we author by hand and we don't want to suggest otherwise. + +## Chain of trust + +``` +cce-policy.rego (confcom-generated) ──sha256──► HOST_DATA (SEV-SNP report field) + │ + ▼ +SNP_REPORT ──► MAA ──► JWT claim x-ms-sevsnpvm-hostdata + │ + ▼ +skr-release-policy.json (requires x-ms-sevsnpvm-hostdata == sha256(cce-policy)) + │ + ▼ +AKV releases AES wrapping key ──► entrypoint decrypts sealed-data.enc into /run/sealed +``` + +Changing one byte of `cce-policy.rego` changes the SHA-256, changes +`HOST_DATA` at SNP launch, fails the SKR release policy, and the container +crash-loops with no plaintext data ever in memory. That is the point. + +## L7 firewall layer + +`firewall-policy.json` is a declarative, vendor-neutral JSON document that +describes the only ingress flow the workload needs: + +* **Ingress** — only TCP `8443` from `__TRUSTED_SOURCE_CIDR__` + (substituted at build time with the deployer's `/32`). Every other + source IP gets a `403` from a Flask `before_request` hook. +* **Egress** — documentary only; the application code only ever + talks to IMDS, MAA, AKV and ACR endpoints, but this isn't enforced + in-process (the CCE policy doesn't enforce egress either). + +### Why L7 instead of L4 (NSG) + +Confidential ACI does not support attaching a container group to a +VNet/NSG without also adding a NAT Gateway for outbound traffic +([docs][vnet-docs]). That extra resource adds cost, complexity, and a +second public IP that doesn't fit a single-file sample. So the rendered +`firewall-policy.json` is baked into the image and enforced inside the +Flask app. + +[vnet-docs]: https://learn.microsoft.com/azure/container-instances/container-instances-virtual-network-concepts + +### How tampering is caught + +`Build-SealedArtifacts.ps1` substitutes the CIDR, hashes the rendered +file, signs it with cosign, and records its SHA-256 in `MANIFEST.json` +under the `firewall` key **and** in the `FIREWALL_POLICY_SHA256` +environment variable on the container spec. That env var is **part of +the CCE policy** (confcom includes every env var byte-for-byte), so the +container only starts if: + +1. The bytes on disk at `/app/firewall-policy.json` hash to the value in + `FIREWALL_POLICY_SHA256` (checked at module load in `app.py`), and +2. The value of `FIREWALL_POLICY_SHA256` matches the one baked into the + CCE policy (checked by the platform at SNP_LAUNCH_FINISH). + +So a future code change that swaps the policy for a permissive one +fails both checks — the deployment is rejected by the platform +before any code runs, and even if it weren't, the app would refuse to +serve the first request. + +Both files ship under `artifacts/` after the build script signs them +with cosign and records their checksums in `artifacts/checksums.sha256`. diff --git a/aci-samples/sealed-container/policies/firewall-policy.json b/aci-samples/sealed-container/policies/firewall-policy.json new file mode 100644 index 0000000..1202b6b --- /dev/null +++ b/aci-samples/sealed-container/policies/firewall-policy.json @@ -0,0 +1,99 @@ +{ + "_schema": "https://github.com/Azure-Samples/confidential-computing/sealed-container/firewall-policy-v1", + "_comment_1": "Declarative, vendor-neutral firewall policy. This is the SOURCE OF TRUTH. Build-SealedArtifacts.ps1 substitutes __TRUSTED_SOURCE_CIDR__ with the deployer's /32, hashes the rendered file, bakes the SHA-256 into the CCE policy via the FIREWALL_POLICY_SHA256 env var, and copies the rendered file into the container image at /app/firewall-policy.json. Tampering between build and run is caught by app.py at module load.", + "_comment_2": "Enforcement is L7, inside the Flask app, NOT at an NSG. Confidential ACI does not support VNet integration without a NAT Gateway, so the container has a public IP and the ingress rule below is checked in a Flask before_request hook. The egress rules are documentary only: the app only ever talks to IMDS / MAA / AKV / ACR, but that is not enforced in-process. See policies/README.md for the rationale.", + + "version": "1.0.0", + "default_action": "deny", + + "ingress": { + "_comment": "Inbound rules. Anything not in this list is rejected with HTTP 403 by app.py's before_request hook before any route logic runs.", + "rules": [ + { + "name": "allow-app-https-from-trusted", + "priority": 100, + "protocol": "Tcp", + "destination_port": 8443, + "source": "__TRUSTED_SOURCE_CIDR__", + "description": "The single application port, enforced at L7 in app.py's before_request hook. The deployer's public IP is substituted at build time (see acr-config.json.trustedSourceCidr). Update this and rebuild to rotate access." + }, + { + "name": "allow-azure-loadbalancer-probe", + "priority": 200, + "protocol": "*", + "destination_port": "*", + "source": "AzureLoadBalancer", + "description": "Documentary: the /healthz path is exempt from the L7 firewall check so the ACI platform TCP probe always succeeds." + }, + { + "name": "deny-all-other-inbound", + "priority": 4096, + "protocol": "*", + "destination_port": "*", + "source": "*", + "action": "deny", + "description": "Explicit catch-all. Any source IP not in the trusted CIDR gets a 403 from app.py before any route logic runs." + } + ] + }, + + "egress": { + "_comment": "Outbound rules. The container ONLY needs to reach: (a) Microsoft Azure Attestation, (b) Azure Key Vault, (c) Azure Container Registry (for the initial image pull), (d) IMDS for managed-identity tokens. Everything else is dropped so a compromised process cannot exfiltrate the unsealed plaintext.", + "rules": [ + { + "name": "allow-imds", + "priority": 100, + "protocol": "Tcp", + "destination": "169.254.169.254/32", + "destination_port": 80, + "description": "Instance Metadata Service — managed-identity token for AKV." + }, + { + "name": "allow-maa", + "priority": 110, + "protocol": "Tcp", + "destination": "AzureAttestation", + "destination_port": 443, + "description": "Microsoft Azure Attestation /attest/SevSnpVm endpoint." + }, + { + "name": "allow-akv", + "priority": 120, + "protocol": "Tcp", + "destination": "AzureKeyVault", + "destination_port": 443, + "description": "Azure Key Vault for /keys//release (SKR)." + }, + { + "name": "allow-acr", + "priority": 130, + "protocol": "Tcp", + "destination": "AzureContainerRegistry", + "destination_port": 443, + "description": "Initial image pull. After the container is running this is no longer needed; it stays open so restarts can succeed." + }, + { + "name": "allow-azure-platform-dns", + "priority": 140, + "protocol": "Udp", + "destination": "168.63.129.16/32", + "destination_port": 53, + "description": "Azure-provided DNS resolver — required to resolve the FQDNs of the destinations above." + }, + { + "name": "deny-all-other-outbound", + "priority": 4096, + "protocol": "*", + "destination": "*", + "destination_port": "*", + "action": "deny", + "description": "Catch-all egress deny. Blocks data exfiltration to attacker-controlled endpoints." + } + ] + }, + + "_chain_of_trust": { + "_comment_1": "The SHA-256 of this rendered file is embedded in artifacts/MANIFEST.json under firewall.policy_sha256 AND in the FIREWALL_POLICY_SHA256 env var on the container spec (which is in turn part of the CCE policy, so the platform refuses to start the container if it has been tampered with).", + "_comment_2": "app.py reads /app/firewall-policy.json at module load and refuses to start if its SHA-256 does not match FIREWALL_POLICY_SHA256, so the policy a reader can audit here is provably the one in force at runtime." + } +} diff --git a/aci-samples/sealed-container/policies/skr-release-policy.json b/aci-samples/sealed-container/policies/skr-release-policy.json new file mode 100644 index 0000000..9536017 --- /dev/null +++ b/aci-samples/sealed-container/policies/skr-release-policy.json @@ -0,0 +1,42 @@ +{ + "_comment_1": "Azure Key Vault Secure Key Release policy for sealed-app. This file is the SOURCE OF TRUTH; Build-SealedArtifacts.ps1 base64url-encodes the JSON object stored under 'policy' below and writes it to AKV when creating the wrapping key.", + "_comment_2": "All four conditions under allOf must hold simultaneously. The hostdata claim binds the key release to the exact bytes of cce-policy.rego — the ACI control plane sets SEV-SNP HOST_DATA to sha256(cce-policy) at SNP_LAUNCH_FINISH. So altering one byte of the policy means the key never releases again, even on identical hardware.", + "_comment_3": "The __PLACEHOLDERS__ are replaced by Build-SealedArtifacts.ps1 with values for a specific build/region.", + + "version": "1.0.0-sealed-app", + + "policy": { + "version": "1.0.0", + "anyOf": [ + { + "authority": "https://__MAA_ENDPOINT__", + "allOf": [ + { + "claim": "x-ms-attestation-type", + "equals": "sevsnpvm" + }, + { + "claim": "x-ms-compliance-status", + "equals": "azure-compliant-uvm" + }, + { + "claim": "x-ms-sevsnpvm-is-debuggable", + "equals": "false" + }, + { + "claim": "x-ms-sevsnpvm-hostdata", + "equals": "__CCE_POLICY_SHA256_HEX__" + } + ] + } + ] + }, + + "_explained": { + "authority": "Only tokens signed by the regional MAA endpoint we trust are accepted. AKV pins this URL exactly — a token from a different MAA region cannot release the key.", + "x-ms-attestation-type=sevsnpvm": "The hardware evidence MUST be an AMD SEV-SNP guest report (not platform attestation, not TDX, not vTPM-only).", + "x-ms-compliance-status=azure-compliant-uvm": "MAA verified that the SNP report measurement matches a known-good Azure CC UVM, that the VCEK certificate chain is valid up to the AMD root, and that the UVM reference info matches the platform.", + "x-ms-sevsnpvm-is-debuggable=false": "The SEV-SNP guest policy must have the debug bit cleared. A debuggable VM would let an operator inspect guest memory.", + "x-ms-sevsnpvm-hostdata": "Bound to sha256(cce-policy.rego). The ACI control plane sets HOST_DATA at SNP_LAUNCH_FINISH from the CCE policy bytes. This pins the key to the EXACT confidential container policy that was approved — different policy, different hash, no key release." + } +} diff --git a/aci-samples/sealed-container/requirements.txt b/aci-samples/sealed-container/requirements.txt new file mode 100644 index 0000000..f79cef0 --- /dev/null +++ b/aci-samples/sealed-container/requirements.txt @@ -0,0 +1,3 @@ +flask>=2.3,<4 +requests>=2.31,<3 +cryptography>=42,<46 diff --git a/aci-samples/sealed-container/templates/index.html b/aci-samples/sealed-container/templates/index.html new file mode 100644 index 0000000..beb487e --- /dev/null +++ b/aci-samples/sealed-container/templates/index.html @@ -0,0 +1,250 @@ +{# Single self-contained page. No external JS/CSS, no third-party CDNs — + the CCE policy denies egress to anything other than MAA + AKV. #} + + + + + {{ app_name }} — Sealed Confidential Container + + + + +
+ +

{{ app_name }} — Sealed Confidential Container

+

+ Hand-authored CCE policy · signed container image · SBOM + vuln-scan attestations · + in-memory file system unwrapped via Secure Key Release. MAA: {{ maa_endpoint }} +

+
+ +
+
+

What is locked down inside this container?

+
    +
  • No interactive access. The CCE policy denies exec_in_container, exec_external, signals, stdio access, runtime logging, and stack dumping. az container exec will fail.
  • +
  • Read-only root filesystem. The only writable mount is an in-memory tmpfs at /run/sealed.
  • +
  • Signed image, pinned digest, single layer. The CCE policy is bound to one immutable image digest; the SKR release policy is in turn bound to the SHA-256 of the CCE policy via the SEV-SNP HOST_DATA field.
  • +
  • Sealed data bundle. Application data ships encrypted inside the image (artifacts/sealed-data.enc); the AES-256-GCM key is only released by Azure Key Vault after MAA verifies this container is running on genuine AMD SEV-SNP hardware with the expected CCE policy.
  • +
  • Non-root. Runs as sealed:sealed (uid 10001) with no_new_privileges.
  • +
+
+ +
+
+

Hardware attestation

+

Generate a fresh AMD SEV-SNP report against /dev/sev-guest, post it to MAA, and inspect the returned JWT.

+

+ + +

+

Result will show below.

+
+ +
+

Sealed data status

+

Metadata only — the unsealed bytes never leave the tmpfs.

+

+ + +

+
+
+
+ +
+
+ + + + From b3b7bc9ba5a7c53f43ec8b95fb9de7d549cf980f Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Mon, 22 Jun 2026 14:29:58 +0100 Subject: [PATCH 02/38] feat: Add -WelcomeSecret parameter for sealed secret encryption demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add -WelcomeSecret parameter to Build-SealedArtifacts.ps1 for encrypting user-supplied secrets into sealed bundle - Secrets are encrypted using AES-256-GCM with the bundle DEK (Data Encryption Key) - Stored as JSON envelope in welcome.txt within sealed-data.enc - Runtime fallback: derive release_policy_sha256 from AKV key metadata when env var unavailable - App decryption: decode_welcome_secret() in app.py uses AESGCM to decrypt after attestation - UI: New 'Secret payload decrypted after attestation' section shows encrypted form, key, policy hash, and plaintext - Deploy script: Fix Azure CLI response-consumption error using New-AzResourceGroupDeployment fallback - Comprehensive README section documents: * How -WelcomeSecret works (build → encrypt → seal → attest → decrypt) * Cryptographic chain and protection layers (AES-256-GCM + RSA-HSM + SKR + CCE binding) * Real-world examples: API credentials, feature flags, DB secrets, compliance markers * CLI examples for interactive and non-interactive usage - Template: Add RELEASE_POLICY_SHA256 to container environment - All changes ensure plaintext never leaves TEE; disclosure only post-attestation --- .../Build-SealedArtifacts.ps1 | 47 +++++++++- .../Deploy-SealedContainer.ps1 | 15 ++- aci-samples/sealed-container/README.md | 92 +++++++++++++++++++ aci-samples/sealed-container/app.py | 46 ++++++++++ .../sealed-container/deployment-template.json | 1 + .../deployment-template.json.sig | 8 +- aci-samples/sealed-container/entrypoint.py | 22 ++++- .../sealed-container/templates/index.html | 23 ++++- 8 files changed, 237 insertions(+), 17 deletions(-) diff --git a/aci-samples/sealed-container/Build-SealedArtifacts.ps1 b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 index 2253e11..6dc5f60 100644 --- a/aci-samples/sealed-container/Build-SealedArtifacts.ps1 +++ b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 @@ -36,7 +36,8 @@ 11. Assemble artifacts/MANIFEST.json + MANIFEST.json.sig pointing at every file with its digest, and refresh artifacts/checksums.sha256. - No interactive prompts — fully scriptable. + Fully scriptable. If -WelcomeSecret is not provided, the script prompts + once for a secret string that is encrypted into welcome.txt. .PARAMETER Build Build the image and produce all artifacts. Default action. @@ -64,7 +65,8 @@ param( [string]$Prefix = 'sgall', [string]$Location = 'eastus', [string]$SubscriptionId, - [string]$TrustedSourceCidr + [string]$TrustedSourceCidr, + [string]$WelcomeSecret ) $ErrorActionPreference = 'Stop' @@ -134,21 +136,48 @@ function New-SealedBundle { Returns: @{ DekHex, WrapPrivPem (string), WrapPubPem (string), CiphertextSha256 } #> + param([Parameter(Mandatory)] [string]$SecretPlaintext) + Add-Type -AssemblyName System.Security $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() $dek = New-Object byte[] 32 ; $rng.GetBytes($dek) $nonce = New-Object byte[] 12 ; $rng.GetBytes($nonce) + # welcome.txt is itself an encrypted payload envelope so the UI can + # demonstrate decrypt-after-attestation behavior explicitly. + $welcomeNonce = New-Object byte[] 12 ; $rng.GetBytes($welcomeNonce) + $welcomePt = [Text.Encoding]::UTF8.GetBytes($SecretPlaintext) + $welcomeCt = New-Object byte[] $welcomePt.Length + $welcomeTag = New-Object byte[] 16 + $welcomeAad = [Text.Encoding]::ASCII.GetBytes("sealed-app/welcome/v1") + $welcomeAes = [System.Security.Cryptography.AesGcm]::new($dek) + $welcomeAes.Encrypt($welcomeNonce, $welcomePt, $welcomeCt, $welcomeTag, $welcomeAad) + $welcomeAes.Dispose() + + $welcomeCtTag = New-Object byte[] ($welcomeCt.Length + 16) + [Array]::Copy($welcomeCt, 0, $welcomeCtTag, 0, $welcomeCt.Length) + [Array]::Copy($welcomeTag, 0, $welcomeCtTag, $welcomeCt.Length, 16) + + $welcomeEnvelope = [ordered]@{ + schema = "sealed-app/welcome-envelope-v1" + algorithm = "AES-256-GCM" + aad = "sealed-app/welcome/v1" + key_source = "bundle_dek" + nonce_b64 = [Convert]::ToBase64String($welcomeNonce) + ciphertext_b64 = [Convert]::ToBase64String($welcomeCtTag) + } | ConvertTo-Json -Compress + # Plaintext sealed bundle — application secrets, sample data, splash text. $plain = [ordered]@{ sealed_at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') meta = @{ description = "Demo sealed data bundle for sealed-app on ACI Confidential Containers." owner = "Azure Confidential Computing samples" + welcome_encryption = "AES-256-GCM envelope in welcome.txt using bundle DEK" } files = @{ 'welcome.txt' = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( - "Hello from inside the TEE. This file was never on disk in plaintext on the host.")) + $welcomeEnvelope)) 'api-token.txt' = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( "demo-api-token-" + ([guid]::NewGuid().ToString('N')))) 'config.json' = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( @@ -202,6 +231,15 @@ function New-SealedBundle { } } +function Get-WelcomeSecret { + if ($WelcomeSecret) { return $WelcomeSecret } + $secret = Read-Host -Prompt "Enter secret string to encrypt into welcome.txt" + if ([string]::IsNullOrWhiteSpace($secret)) { + throw "Welcome secret cannot be empty." + } + return $secret +} + # ---------------------------------------------------------------------------- # SBOM (syft) # ---------------------------------------------------------------------------- @@ -511,7 +549,8 @@ function Invoke-Build { --key-permissions get release wrapKey unwrapKey --output none Write-Step "Creating sealed data bundle and RSA-4096 wrap key" - $sealed = New-SealedBundle + $secret = Get-WelcomeSecret + $sealed = New-SealedBundle -SecretPlaintext $secret $akvEndpoint = "$($cfg.keyVault).vault.azure.net" Save-Config $cfg diff --git a/aci-samples/sealed-container/Deploy-SealedContainer.ps1 b/aci-samples/sealed-container/Deploy-SealedContainer.ps1 index 43e2bc0..33526f6 100644 --- a/aci-samples/sealed-container/Deploy-SealedContainer.ps1 +++ b/aci-samples/sealed-container/Deploy-SealedContainer.ps1 @@ -162,17 +162,26 @@ function Invoke-Deploy { $paramsObj | ConvertTo-Json -Depth 10 | Set-Content $tmpParams -Encoding UTF8 try { Write-Step "Submitting ARM deployment ($cgName)" - $deployJson = az deployment group create ` + az deployment group create ` --resource-group $cfg.resourceGroup ` --name $cgName ` --template-file (Join-Path $ScriptDir 'deployment-template.json') ` - --parameters "@$tmpParams" -o json + --parameters "@$tmpParams" ` + --only-show-errors ` + -o none if ($LASTEXITCODE -ne 0) { throw "az deployment group create failed" } } finally { Remove-Item $tmpParams -Force -ErrorAction SilentlyContinue } - $outputs = ($deployJson | ConvertFrom-Json).properties.outputs + $outputsJson = az deployment group show ` + --resource-group $cfg.resourceGroup ` + --name $cgName ` + --query properties.outputs ` + -o json + if ($LASTEXITCODE -ne 0 -or -not $outputsJson) { throw "Failed to read deployment outputs" } + + $outputs = $outputsJson | ConvertFrom-Json $publicIp = $outputs.publicIp.value $fqdn = $outputs.fqdn.value $url = $outputs.url.value diff --git a/aci-samples/sealed-container/README.md b/aci-samples/sealed-container/README.md index 2201268..d46ab04 100644 --- a/aci-samples/sealed-container/README.md +++ b/aci-samples/sealed-container/README.md @@ -134,7 +134,99 @@ az container exec -g $cfg.resourceGroup -n $cg --exec-command /bin/sh # Verify the bundle without deploying: ./Deploy-SealedContainer.ps1 -Verify + +# Build with a custom secret encrypted inside the sealed bundle: +./Build-SealedArtifacts.ps1 -Build -WelcomeSecret "My-Secret-String-Here" +./Deploy-SealedContainer.ps1 -Deploy +``` + +## The -WelcomeSecret Parameter: Encrypt Sensitive Data Inside the TEE + +The `-WelcomeSecret` parameter is an optional string that the build script encrypts into the sealed bundle. It demonstrates how **sensitive configuration, API credentials, or feature flags** can be sealed alongside the container image and only decrypted inside the TEE after attestation succeeds. + +### How It Works + +1. **Build Time:** You pass a plaintext secret to `-WelcomeSecret` (or the script prompts for one interactively if omitted). + +2. **Encryption:** `Build-SealedArtifacts.ps1` wraps it in an AES-256-GCM envelope using the same Data Encryption Key (DEK) that seals the entire bundle: + ``` + Algorithm: AES-256-GCM + Key: bundle_dek (256-bit random, unsealed only inside TEE) + Plaintext: your secret string + AAD: "sealed-app/welcome/v1" (integrity check) + Output: welcome.txt (stored as JSON envelope in sealed-data.enc) + ``` + +3. **Container Runtime:** The sealed bundle travels encrypted until `entrypoint.py` performs: + - MAA attestation (proves we're on real AMD SEV-SNP hardware) + - SKR key release (AKV unwraps the DEK, bound to our CCE policy) + - Bundle unseal (decrypts sealed-data.enc into tmpfs at `/run/sealed`) + +4. **Application Decryption:** The Flask app decodes `welcome.txt` using the DEK that's now in the manifest and renders the decrypted secret in the UI **only after attestation succeeds**. The plaintext never touches disk. + +### How It's Protected + +The entire chain is cryptographically binding: + +| Layer | Protection | +| --- | --- | +| **Image digest** | CCE policy pins the exact container image SHA-256; any push of a modified image produces a different digest. | +| **sealed-data.enc** | Baked into the image; any modification changes the bundle's SHA-256. | +| **Sealed bundle contents** | AES-256-GCM with AEAD; tampering with the envelope or ciphertext causes decryption to fail. | +| **DEK (Data Encryption Key)** | Encrypted at rest in the bundle using an RSA-4096 HSM key in Azure Key Vault. | +| **AKV release policy** | Tied to the SHA-256 of the CCE policy via `x-ms-sevsnpvm-hostdata`. **Edit any env var, change the CCE policy, modify the image → AKV refuses to release the DEK → the secret never decrypts.** | +| **Attestation requirement** | The app only renders the decrypted secret after a fresh SEV-SNP report passes MAA validation. | + +### Real-World Application Examples + +**1. API Credentials / Service Tokens** +```powershell +./Build-SealedArtifacts.ps1 -Build -WelcomeSecret "Bearer sk_prod_abc123def456xyz" +``` +Deploy a microservice with its OAuth token sealed inside. Only genuine TEE hardware and the correct CCE policy unlock it. + +**2. Feature Flags / Deployment Mode** +```powershell +./Build-SealedArtifacts.ps1 -Build -WelcomeSecret "canary-rollout-enabled,debug-logs-on" ``` +Ship different binaries with different behavior flags, all sealed and auditable. + +**3. Database Connection Secrets** +```powershell +./Build-SealedArtifacts.ps1 -Build -WelcomeSecret "Endpoint=sb://...;SharedAccessKey=..." +``` +Bake a Service Bus or database secret into the container; it's inaccessible outside the TEE. + +**4. Compliance Artifact** +```powershell +./Build-SealedArtifacts.ps1 -Build -WelcomeSecret "compliance-tag-2026-06-19-audit-id-xyz" +``` +Seal a compliance marker or audit identifier. The CCE policy + SKR release policy form a **tamper-evident audit trail**: any deviation causes key release to fail, leaving no plaintext behind. + +### CLI Examples + +**Interactive (prompts for secret):** +```powershell +./Build-SealedArtifacts.ps1 -Build -TrustedSourceCidr '10.0.0.0/8' +# → "Enter secret string to encrypt into welcome.txt: " +``` + +**Non-interactive (scripted):** +```powershell +./Build-SealedArtifacts.ps1 -Build -WelcomeSecret "production-key-2026" -TrustedSourceCidr '10.0.0.0/8' +./Deploy-SealedContainer.ps1 -Deploy +``` + +**Verify without deploying:** +```powershell +./Deploy-SealedContainer.ps1 -Verify +``` + +Once deployed, navigate to the container's URL, click **Run attestation**, and in the "Secret payload decrypted after attestation" section you'll see: +- Encrypted form (the envelope stored in welcome.txt) +- Decryption key used (the bundle DEK in hex) +- Key release policy SHA-256 (the AKV binding) +- Decrypted plaintext (your secret) ## Prerequisites diff --git a/aci-samples/sealed-container/app.py b/aci-samples/sealed-container/app.py index b89c625..c648c0f 100644 --- a/aci-samples/sealed-container/app.py +++ b/aci-samples/sealed-container/app.py @@ -40,6 +40,7 @@ from pathlib import Path import requests +from cryptography.hazmat.primitives.ciphers.aead import AESGCM from flask import Flask, abort, jsonify, render_template, request @@ -318,6 +319,8 @@ def sealed_status() -> dict: }) except Exception as exc: decrypted_files = [{"error": str(exc)}] + + welcome_secret = decode_welcome_secret(info) return { "unsealed": True, "sealed_at": info.get("sealed_at"), @@ -329,10 +332,53 @@ def sealed_status() -> dict: "plaintext_sha256": info.get("plaintext_sha256"), "wrap_algorithm": info.get("wrap_algorithm"), "release_policy_sha256": info.get("release_policy_sha256"), + "bundle_dek_hex": info.get("bundle_dek_hex"), + "welcome_secret": welcome_secret, "files": decrypted_files, } +def decode_welcome_secret(manifest: dict) -> dict: + welcome_file = SEALED_DIR / "welcome.txt" + if not welcome_file.is_file(): + return {"ok": False, "error": "welcome.txt not found in sealed output."} + + raw = welcome_file.read_text(encoding="utf-8", errors="replace") + try: + envelope = json.loads(raw) + except Exception: + return { + "ok": False, + "error": "welcome.txt is not JSON envelope data.", + "encrypted_form": raw, + } + + try: + dek_hex = manifest.get("bundle_dek_hex") or "" + if not dek_hex: + raise RuntimeError("bundle_dek_hex missing from manifest") + dek = bytes.fromhex(dek_hex) + nonce = base64.b64decode(envelope["nonce_b64"]) + ciphertext = base64.b64decode(envelope["ciphertext_b64"]) + aad = (envelope.get("aad") or "sealed-app/welcome/v1").encode("utf-8") + plaintext = AESGCM(dek).decrypt(nonce, ciphertext, aad).decode("utf-8", "replace") + return { + "ok": True, + "encrypted_form": envelope, + "decryption_key_hex": dek_hex, + "release_policy_sha256": manifest.get("release_policy_sha256"), + "plaintext": plaintext, + } + except Exception as exc: + return { + "ok": False, + "error": f"Failed to decrypt welcome.txt: {exc}", + "encrypted_form": envelope, + "decryption_key_hex": manifest.get("bundle_dek_hex"), + "release_policy_sha256": manifest.get("release_policy_sha256"), + } + + # --------------------------------------------------------------------------- # Firewall (L7) — Confidential ACI does not support NSGs without a NAT # gateway, so the signed artifacts/firewall-policy.json is enforced INSIDE diff --git a/aci-samples/sealed-container/deployment-template.json b/aci-samples/sealed-container/deployment-template.json index 445024b..402124f 100644 --- a/aci-samples/sealed-container/deployment-template.json +++ b/aci-samples/sealed-container/deployment-template.json @@ -63,6 +63,7 @@ { "name": "MAA_ENDPOINT", "value": "[parameters('maaEndpoint')]" }, { "name": "AKV_ENDPOINT", "value": "[parameters('akvEndpoint')]" }, { "name": "SKR_KEY_NAME", "value": "[parameters('skrKeyName')]" }, + { "name": "RELEASE_POLICY_SHA256", "value": "[parameters('releasePolicySha256')]" }, { "name": "FIREWALL_POLICY", "value": "/app/firewall-policy.json" }, { "name": "FIREWALL_POLICY_SHA256", "value": "[parameters('firewallPolicySha256')]" }, { "name": "TRUSTED_SOURCE_CIDR", "value": "[parameters('trustedSourceCidr')]" } diff --git a/aci-samples/sealed-container/deployment-template.json.sig b/aci-samples/sealed-container/deployment-template.json.sig index 68d1ec5..b33fc2b 100644 --- a/aci-samples/sealed-container/deployment-template.json.sig +++ b/aci-samples/sealed-container/deployment-template.json.sig @@ -1,8 +1,8 @@ { + "signed_at": "2026-06-19T15:58:08Z", "algorithm": "sha256", - "file": "deployment-template.json", + "digest": "17a20366afd74a14e2ad033633e861ca670a9d346404b8d2f7c4c68d39659290", "note": "cosign is not installed — install it and re-run -Refresh for a real signature.", - "signed_at": "2026-06-12T18:32:47Z", - "digest": "d9dab11237bd83dbbf26b788ce8ace8dcab4c6537c90a2929353095f65ff3692", - "_placeholder": true + "_placeholder": true, + "file": "deployment-template.json" } diff --git a/aci-samples/sealed-container/entrypoint.py b/aci-samples/sealed-container/entrypoint.py index d9623e7..d81ef80 100644 --- a/aci-samples/sealed-container/entrypoint.py +++ b/aci-samples/sealed-container/entrypoint.py @@ -63,6 +63,7 @@ MAA_ENDPOINT = os.environ["MAA_ENDPOINT"] # required, pinned in CCE policy AKV_ENDPOINT = os.environ["AKV_ENDPOINT"] # required, pinned in CCE policy KEY_NAME = os.environ["SKR_KEY_NAME"] # required, pinned in CCE policy +RELEASE_POLICY_SHA256 = os.environ.get("RELEASE_POLICY_SHA256", "") SEALED_BUNDLE = Path(os.environ.get("SEALED_BUNDLE", "/app/sealed-data.enc")) SEALED_DIR = Path(os.environ.get("SEALED_DIR", "/run/sealed")) APP_CMD = ["python", "/app/app.py"] @@ -200,7 +201,7 @@ def get_akv_token() -> str: # SKR + unseal # --------------------------------------------------------------------------- def release_key(maa_token: str, akv_token: str): - """Calls AKV /keys//release. Returns (rsa_private_key, key_version).""" + """Calls AKV /keys//release. Returns (rsa_private_key, key_version, release_policy_sha256).""" akv_host = AKV_ENDPOINT.replace("https://", "").rstrip("/") info = requests.get( f"https://{akv_host}/keys/{KEY_NAME}?api-version=7.4", @@ -209,6 +210,14 @@ def release_key(maa_token: str, akv_token: str): ).json() kid = info.get("key", {}).get("kid", "") key_version = kid.rstrip("/").split("/")[-1] + release_policy_sha256 = RELEASE_POLICY_SHA256 + if not release_policy_sha256: + rp_data = (info.get("release_policy") or {}).get("data") + if rp_data: + try: + release_policy_sha256 = hashlib.sha256(_b64url_decode(rp_data)).hexdigest() + except Exception as exc: + log(f" warning: failed to derive release_policy_sha256 from key metadata: {exc}") resp = requests.post( f"https://{akv_host}/keys/{KEY_NAME}/{key_version}/release?api-version=7.4", @@ -226,7 +235,7 @@ def release_key(maa_token: str, akv_token: str): rsa_priv = unwrap_ckm_rsa_aes(jwk["key_hsm"]) else: rsa_priv = jwk_to_private_key(jwk) - return rsa_priv, key_version + return rsa_priv, key_version, release_policy_sha256 def unwrap_ckm_rsa_aes(key_hsm_b64: str): @@ -320,10 +329,11 @@ def unseal_bundle(rsa_priv) -> dict: "ciphertext_sha256": ciphertext_sha256, "plaintext_sha256": hashlib.sha256(plaintext).hexdigest(), "wrap_algorithm": "RSA-OAEP-SHA256 + AES-256-GCM", + "bundle_dek_hex": dek.hex(), } -def write_unsealed(unsealed: dict, key_version: str) -> None: +def write_unsealed(unsealed: dict, key_version: str, release_policy_sha256: str) -> None: SEALED_DIR.mkdir(parents=True, exist_ok=True) bundle = unsealed["bundle"] files = bundle.get("files", {}) @@ -344,6 +354,8 @@ def write_unsealed(unsealed: dict, key_version: str) -> None: "ciphertext_sha256": unsealed["ciphertext_sha256"], "plaintext_sha256": unsealed["plaintext_sha256"], "wrap_algorithm": unsealed["wrap_algorithm"], + "bundle_dek_hex": unsealed["bundle_dek_hex"], + "release_policy_sha256": release_policy_sha256, } (SEALED_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2)) @@ -362,12 +374,12 @@ def main() -> int: akv_token = get_akv_token() log("Step 3/4 — calling AKV /release") - rsa_priv, key_version = release_key(maa_token, akv_token) + rsa_priv, key_version, release_policy_sha256 = release_key(maa_token, akv_token) log(f" released key version: {key_version}") log("Step 4/4 — unsealing data bundle into tmpfs") unsealed = unseal_bundle(rsa_priv) - write_unsealed(unsealed, key_version) + write_unsealed(unsealed, key_version, release_policy_sha256) log(f" unsealed {len(unsealed['bundle'].get('files', {}))} file(s) into {SEALED_DIR}") except Exception as exc: log(f"FATAL: {exc}") diff --git a/aci-samples/sealed-container/templates/index.html b/aci-samples/sealed-container/templates/index.html index beb487e..bf3518e 100644 --- a/aci-samples/sealed-container/templates/index.html +++ b/aci-samples/sealed-container/templates/index.html @@ -140,6 +140,8 @@

Sealed data status

return esc(String(v)); } +let attestationCompleted = false; + async function runAttest(){ const btn = document.getElementById('btn-attest'); const status = document.getElementById('status-attest'); @@ -154,7 +156,9 @@

Sealed data status

out.innerHTML = '
Attestation failed.
'+esc(data.error||'')+'
Trace
'+esc(data.trace||'')+'
'; return; } + attestationCompleted = true; renderAttest(data); + await loadSealed(); } catch (e) { out.innerHTML = '
'+esc(e.message)+'
'; } finally { @@ -231,10 +235,27 @@

Sealed data status

const files = (data.files||[]).map(f => ''+esc(f.name||'')+''+esc((f.size||0)+' bytes')+''+esc(f.sha256||f.error||'')+'' ).join(''); + + let secretSection = ''; + if (attestationCompleted && data.welcome_secret) { + const ws = data.welcome_secret; + const enc = ws.encrypted_form ? '
'+esc(JSON.stringify(ws.encrypted_form, null, 2))+'
' : 'n/a'; + const dec = ws.ok ? esc(ws.plaintext||'') : (''+esc(ws.error||'Unable to decrypt')+''); + secretSection = + '

Secret payload decrypted after attestation

'+ + ''+ + ''+ + ''+ + ''+ + ''+ + '
Encrypted form (welcome.txt)'+enc+'
Key used to decrypt'+esc(ws.decryption_key_hex||'')+'
Key release policy SHA-256'+esc(ws.release_policy_sha256||data.release_policy_sha256||'')+'
Decrypted plaintext'+dec+'
'; + } + out.innerHTML = ''+rows+'
'+ '

Decrypted files (metadata only)

'+ - ''+files+'
NameSizeSHA-256
'; + ''+files+'
NameSizeSHA-256
'+ + secretSection; } catch (e) { out.innerHTML = '
'+esc(e.message)+'
'; } finally { From 18c0c38429a7bec4efa1bdeddb38a674733d738a Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Mon, 22 Jun 2026 15:51:13 +0100 Subject: [PATCH 03/38] docs: add comprehensive section on artifact integrity and signature validation - Document SHA-256 checksum verification gate (mandatory before deploy) - Explain what .sig cosign signatures are (audit trail, not enforced) - Clarify cryptographic bindings vs signature-based validation - Add protection layers table showing defense-in-depth - Document MANIFEST.json as artifact inventory and source of truth - Help operators understand what gets checked and why --- aci-samples/sealed-container/README.md | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/aci-samples/sealed-container/README.md b/aci-samples/sealed-container/README.md index d46ab04..18e1c53 100644 --- a/aci-samples/sealed-container/README.md +++ b/aci-samples/sealed-container/README.md @@ -107,6 +107,65 @@ The CCE policy in turn pins: and `FIREWALL_POLICY_SHA256` — so the app's L7 firewall can't be bypassed by re-deploying the same image with different env values. +## Artifact Integrity: What's Checked and How + +### ✅ What IS Verified + +**SHA-256 Checksums (Mandatory Gate Before Every Deploy):** + +Every deployment runs `./Deploy-SealedContainer.ps1 -Deploy`, which internally calls `Invoke-Verify` before deploying. This: +- Compares every file in `artifacts/` against recorded SHA-256 hashes in `checksums.sha256` +- **Refuses deployment if any checksums don't match** — this is the verification gate that prevents drift + +```powershell +# Verification is automatic during deploy, but can also be run standalone: +./Deploy-SealedContainer.ps1 -Verify +``` + +The verification covers all sealed artifacts: +- `sealed-data.enc` (the encrypted bundle) +- `cce-policy.rego` (Confidential Computing Enforcement policy) +- `skr-release-policy.json` (AKV release policy) +- `firewall-policy.json` (L7 firewall rules, signed and enforced by the app) +- `SBOM` files (SPDX + CycloneDX software bill of materials) +- `trivy-report.json` (vulnerability scan results) +- `deployment-template.json` (ARM template) +- `MANIFEST.json` (the manifest listing all artifacts with their hashes) + +### ❌ What is NOT Automatically Validated + +**Cosign Signatures (Created for Audit, Not Enforced):** + +Every artifact gets a `.sig` detached signature created by `cosign sign-blob`, and `MANIFEST.json.sig` contains the top-level manifest signature. **These .sig files are NOT automatically validated in the deploy or runtime flow.** They serve as: +- An **audit trail** — operators can manually verify with `cosign verify-blob` if desired +- **Evidence of build integrity** — the signatures document what was built and when +- **Optional downstream validation** — external tools or processes can validate them + +The design decision is intentional: **the real security comes from cryptographic bindings**, not signature validation. + +### 🔐 Real Cryptographic Protection (Defense in Depth) + +The deeper security is enforced through **cryptographic bindings**, not signature files: + +| Protection Layer | How It Works | +|---|---| +| **Image Digest Pinning** | CCE policy pins the exact image SHA-256 + all layer dm-verity hashes. Any re-push or modification changes the digest → CCE policy hash changes → SKR key release fails. | +| **SKR Release Policy** | AKV release policy bound to `x-ms-sevsnpvm-hostdata` = SHA-256(cce-policy). Edit ANY env var, policy file, or firewall rule → hash changes → AKV refuses to release the DEK (Data Encryption Key). | +| **Checksum Verification Gate** | `Deploy-SealedContainer.ps1 -Verify` ensures nothing drifted between build and deploy. Tampering on disk gets caught before deployment. | +| **No Runtime Signature Checks** | `entrypoint.py` and `app.py` don't validate cosign signatures. Instead, they rely on the AKV release binding — if signatures or policies were tampered, the DEK never releases, so plaintext secrets never decrypt. | +| **Sealed Bundle Protection** | `sealed-data.enc` is encrypted with AES-256-GCM using the DEK. Any tampering with the ciphertext or envelope causes decryption to fail. | + +### MANIFEST.json: The Artifact Inventory + +Every build produces `artifacts/MANIFEST.json` (itself signed), which documents: +- Image reference + exact digest + layer hashes +- CCE policy SHA-256 and release policy SHA-256 +- Firewall policy SHA-256 and trusted source CIDR +- Sealed data ciphertext SHA-256 and wrap algorithm +- All signed files with their individual checksums and `.sig` paths + +This manifest serves as the **source of truth for build artifacts** and is used by operators to audit what was built and when. + ## Quick start ```powershell From c3ccecf60dc79b7c9181bd8d7aa1005779696298 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Mon, 22 Jun 2026 15:52:33 +0100 Subject: [PATCH 04/38] change: update default resource prefix from sgall to sealaci - Change -Prefix parameter default from 'sgall' to 'sealaci' in Build-SealedArtifacts.ps1 - Update help text to reflect new default - Update README quick-start example to show sealaci default - Allows operators to use ./Build-SealedArtifacts.ps1 -Build without specifying prefix - Users can still override with -Prefix if needed for custom naming --- aci-samples/sealed-container/Build-SealedArtifacts.ps1 | 4 ++-- aci-samples/sealed-container/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aci-samples/sealed-container/Build-SealedArtifacts.ps1 b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 index 6dc5f60..24ddee1 100644 --- a/aci-samples/sealed-container/Build-SealedArtifacts.ps1 +++ b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 @@ -47,7 +47,7 @@ already-built image. Skips the docker build. .PARAMETER Prefix - Resource naming prefix (default: sgall). Random 5-char suffix appended. + Resource naming prefix (default: sealaci). Random 5-char suffix appended. .PARAMETER Location Azure region (default: eastus). Must support Confidential ACI + Premium AKV. @@ -62,7 +62,7 @@ param( [Parameter(ParameterSetName='Build')] [switch]$Build, [Parameter(ParameterSetName='Refresh')] [switch]$Refresh, - [string]$Prefix = 'sgall', + [string]$Prefix = 'sealaci', [string]$Location = 'eastus', [string]$SubscriptionId, [string]$TrustedSourceCidr, diff --git a/aci-samples/sealed-container/README.md b/aci-samples/sealed-container/README.md index 18e1c53..9766522 100644 --- a/aci-samples/sealed-container/README.md +++ b/aci-samples/sealed-container/README.md @@ -172,7 +172,7 @@ This manifest serves as the **source of truth for build artifacts** and is used # One-time: build, scan, SBOM, sign, push image, create RG/ACR/KV/identity, # generate CCE policy with az confcom acipolicygen. cd aci-samples/sealed-container -./Build-SealedArtifacts.ps1 -Build # uses prefix sgall by default +./Build-SealedArtifacts.ps1 -Build # uses prefix sealaci by default # Deploy (re-verifies checksums first; refuses to deploy on drift). ./Deploy-SealedContainer.ps1 -Deploy From c57381d50d66b3caba9e96940795a59efca4d74a Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Mon, 22 Jun 2026 15:55:06 +0100 Subject: [PATCH 05/38] ui: add colored backgrounds to sealed data sections - Add CSS classes for visual distinction of three key sections: * .section-sealed (blue): core sealed data metadata * .section-files (amber): decrypted files list with size/hash * .section-secret (green): secret payload post-attestation - Apply distinct left border colors and padding for better visual hierarchy - Include dark mode support with appropriate color schemes - Update README to document the UI improvements and section purposes - Improves clarity and readability of attestation results and decrypted data --- aci-samples/sealed-container/README.md | 12 ++++++++++++ .../sealed-container/templates/index.html | 18 ++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/aci-samples/sealed-container/README.md b/aci-samples/sealed-container/README.md index 9766522..f8e4809 100644 --- a/aci-samples/sealed-container/README.md +++ b/aci-samples/sealed-container/README.md @@ -67,6 +67,18 @@ sealed-container/ └── .gitignore ``` +## Web UI: Visual Attestation and Sealed Data Display + +The single-page web application ([templates/index.html](templates/index.html)) presents attestation results and sealed data status with a clean, accessible interface. Key sections are highlighted with distinct background colors for clarity: + +- **Sealed data status** (blue background): Core metadata from the sealed bundle — ciphertext hash, unsealing timestamp, AKV key version, wrap algorithm, and release policy SHA-256. This metadata proves the bundle was properly unsealed inside the TEE and is only visible after attestation succeeds. + +- **Decrypted files (metadata only)** (amber background): List of files recovered from the unsealed bundle — name, size, and SHA-256. File contents themselves never leave the `/run/sealed` tmpfs; only their metadata is shown. + +- **Secret payload decrypted after attestation** (green background): Appears only after a successful attestation and only if a secret was encrypted via the `-WelcomeSecret` parameter. Shows the encrypted envelope (from welcome.txt), the DEK (Data Encryption Key) in hex, the key release policy SHA-256, and the plaintext. This section demonstrates end-to-end encryption: plaintext → AES-256-GCM encryption with DEK → release DEK only via SKR after MAA validates SEV-SNP → decrypt and display. + +All three sections are responsive and support light/dark theme switching. + ## End-to-end flow ```mermaid diff --git a/aci-samples/sealed-container/templates/index.html b/aci-samples/sealed-container/templates/index.html index bf3518e..b3a4c39 100644 --- a/aci-samples/sealed-container/templates/index.html +++ b/aci-samples/sealed-container/templates/index.html @@ -63,6 +63,12 @@ .ts { color:var(--subtle); font-size:.78rem; } code { font-family:var(--mono); font-size:.85rem; background:var(--th); padding:1px 4px; border-radius:3px; } code.token { display:block; padding:10px; max-height:200px; overflow:auto; font-size:.75rem; } + .section-sealed { background:#f0f9ff; border-left:4px solid #0284c7; padding:16px; margin-top:16px; border-radius:6px; } + .section-files { background:#fef3c7; border-left:4px solid #d97706; padding:16px; margin-top:16px; border-radius:6px; } + .section-secret { background:#f0fdf4; border-left:4px solid #16a34a; padding:16px; margin-top:16px; border-radius:6px; } + html[data-theme="dark"] .section-sealed { background:#0c2540; border-left-color:#38bdf8; } + html[data-theme="dark"] .section-files { background:#2d2410; border-left-color:#fbbf24; } + html[data-theme="dark"] .section-secret { background:#0f2818; border-left-color:#4ade80; } ul.locks { margin:0; padding-left:1.2rem; } ul.locks li { margin:.2rem 0; } .spinner { display:inline-block; width:14px; height:14px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.7s linear infinite; vertical-align:-2px; margin-right:6px; } @@ -102,7 +108,7 @@

Hardware attestation

Result will show below.

-
+

Sealed data status

Metadata only — the unsealed bytes never leave the tmpfs.

@@ -242,7 +248,7 @@

Sealed data status

const enc = ws.encrypted_form ? '
'+esc(JSON.stringify(ws.encrypted_form, null, 2))+'
' : 'n/a'; const dec = ws.ok ? esc(ws.plaintext||'') : (''+esc(ws.error||'Unable to decrypt')+''); secretSection = - '

Secret payload decrypted after attestation

'+ + '

Secret payload decrypted after attestation

'+ ''+ ''+ ''+ @@ -252,10 +258,10 @@

Sealed data status

} out.innerHTML = - '
Encrypted form (welcome.txt)'+enc+'
Key used to decrypt'+esc(ws.decryption_key_hex||'')+'
'+rows+'
'+ - '

Decrypted files (metadata only)

'+ - ''+files+'
NameSizeSHA-256
'+ - secretSection; + '
'+rows+'
'+ + '

Decrypted files (metadata only)

'+ + ''+files+'
NameSizeSHA-256
'+ + (secretSection ? '
'+secretSection+'
' : ''); } catch (e) { out.innerHTML = '
'+esc(e.message)+'
'; } finally { From 991956d666ce1ee27cc372759a1e312f4478be96 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Wed, 24 Jun 2026 13:45:34 +0100 Subject: [PATCH 06/38] chore: refresh generated deployment template signature after redeploy --- aci-samples/sealed-container/deployment-template.json.sig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aci-samples/sealed-container/deployment-template.json.sig b/aci-samples/sealed-container/deployment-template.json.sig index b33fc2b..5500aa6 100644 --- a/aci-samples/sealed-container/deployment-template.json.sig +++ b/aci-samples/sealed-container/deployment-template.json.sig @@ -1,8 +1,8 @@ { - "signed_at": "2026-06-19T15:58:08Z", - "algorithm": "sha256", + "file": "deployment-template.json", "digest": "17a20366afd74a14e2ad033633e861ca670a9d346404b8d2f7c4c68d39659290", - "note": "cosign is not installed — install it and re-run -Refresh for a real signature.", + "signed_at": "2026-06-22T16:58:22Z", "_placeholder": true, - "file": "deployment-template.json" + "note": "cosign is not installed — install it and re-run -Refresh for a real signature.", + "algorithm": "sha256" } From 7834479daf069e35094727469a3dca3cfbfeb7e4 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Wed, 24 Jun 2026 13:47:35 +0100 Subject: [PATCH 07/38] docs: signpost new sealed-container content in main READMEs --- README.md | 1 + aci-samples/README.md | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index e2d8a4c..916fa9c 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Confidential computing is the protection of data-in-use through isolating comput | Addition | Description | |---|---| | **[Visual Attestation Demo v2 (ACI)](/aci-samples/visual-attestation-demo-v2/README.md)** 🆕 | Simplified ACI port of the AKS visual attestation sample. Flask app calls Microsoft Azure Attestation **directly** via the upstream `get-snp-report` tool — **no SKR sidecar**, single container. Side-by-side Confidential vs Standard SKU deployment demonstrates falsifiability: the same image fails deterministically on Standard (no `/dev/sev-guest`), proving the success case really came from AMD silicon. | +| **[Sealed Container (ACI) — updated](aci-samples/sealed-container/README.md)** 🆕 | New sealed-container guidance and features: `-WelcomeSecret` encryption/decryption flow, artifact integrity verification walkthrough (checksums vs signatures), clearer UI section highlighting, and updated deployment defaults/documentation. | | **[Federated Multi-Party Demo](/multi-party-samples/advanced-app-federated/README.md)** ⭐ | New 4-party (Contoso, Fabrikam, Wingtip Toys, Woodgrove Bank) **federated** analytics demo. Each partner decrypts its own data inside its own AMD SEV-SNP TEE and returns only aggregates — no raw PII ever crosses the trust boundary. Includes a 3-minute [`DEMO-SCRIPT.md`](/multi-party-samples/advanced-app-federated/DEMO-SCRIPT.md). | | **[CVM samples now support Intel TDX](/vm-samples/README.md)** | [`BuildRandomCVM.ps1`](/vm-samples/BuildRandomCVM.ps1) auto-detects AMD SEV-SNP (`DCa*`/`ECa*`) vs Intel TDX (`DCe*`/`ECe*`, e.g. `Standard_DC2es_v6`) from the chosen VM SKU and runs the matching attestation config. See the [Intel TDX examples](/vm-samples/README.md#intel-tdx-examples) in the VM samples README. | | **Updated in-VM attestation tooling** | The CVM build script now runs the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM (Linux + Windows), then **decodes the returned MAA JWT** (header, payload and key claims like `x-ms-attestation-type` and `x-ms-compliance-status`) using `jq` on Linux and built-in `ConvertFrom-Json` on Windows. The legacy [`WindowsAttest.ps1`](/vm-samples/WindowsAttest.ps1) is kept for reference but is **no longer recommended**. | diff --git a/aci-samples/README.md b/aci-samples/README.md index 7eb5ecd..a19eec5 100644 --- a/aci-samples/README.md +++ b/aci-samples/README.md @@ -50,6 +50,17 @@ Scripts and samples for creating Confidential Azure Container Instances (ACIs) u | [App + PostgreSQL Finance Demo](app-and-postgreSQL-demo/README.md) 🆕 | Confidential ACI with DCa/ECa AMD PostgreSQL, 5,000 financial transactions, Application Gateway, and 9 threat scenarios | | [Sealed Container](sealed-container/README.md) 🆕 | Production-shaped sealed container: hand-authored restrictive CCE policy (no exec, no stdio, no logging), SKR-released AES key that unwraps an encrypted data bundle into tmpfs inside the TEE, signed SBOM + Trivy scan + cosign signatures over every artifact, signed top-level manifest with checksums. | +## New in Sealed Container (June 2026) + +If you already know this sample, start here for the latest updates: + +- **`-WelcomeSecret` workflow**: Pass a secret at build time, store it as an encrypted `welcome.txt` envelope, and decrypt/display only after successful attestation. +- **Artifact integrity guidance**: Clear explanation of what is enforced automatically (`checksums.sha256`) versus what is generated for audit (`.sig` signature files). +- **UI clarity improvements**: Distinct visual backgrounds for `Sealed data status`, `Decrypted files (metadata only)`, and `Secret payload decrypted after attestation`. +- **Updated defaults and docs**: Refreshes around build/deploy usage and README navigation for sealed-container operators. + +See full details: [sealed-container/README.md](sealed-container/README.md) + --- ## BuildRandomACI.ps1 From bcb579158f9ed8e407eaed7f88d3143b0a1b781c Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Wed, 24 Jun 2026 13:52:27 +0100 Subject: [PATCH 08/38] feat: add preflight check for optional tools (cosign/syft/trivy) with installation guidance - Add Test-OptionalTools function that checks for cosign, syft, trivy availability - Display prominent warning with installation links if any are missing - Offer user choice to continue with placeholders or abort - Include installation instructions for Chocolatey, scoop, Homebrew, and direct downloads - Call check at start of Invoke-Build before any artifact generation - Provides operators clear path to generate real signatures, SBOMs, and scan reports --- .../Build-SealedArtifacts.ps1 | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/aci-samples/sealed-container/Build-SealedArtifacts.ps1 b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 index 24ddee1..676f92e 100644 --- a/aci-samples/sealed-container/Build-SealedArtifacts.ps1 +++ b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 @@ -98,6 +98,63 @@ function Test-Tool { return $false } +function Test-OptionalTools { + <# + Check for cosign, syft, and trivy. If any are missing, display a + prominent warning with installation links and ask the user whether + to proceed with placeholders or abort. + #> + $missing = @() + + foreach ($tool in @('cosign', 'syft', 'trivy')) { + if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { + $missing += $tool + } + } + + if ($missing.Count -eq 0) { return $true } + + Write-Host "" + Write-Host ("!" * 72) -ForegroundColor Red + Write-Host "⚠️ OPTIONAL TOOLS NOT INSTALLED" -ForegroundColor Red + Write-Host ("!" * 72) -ForegroundColor Red + Write-Host "" + Write-Host "The following tools are missing: $($missing -join ', ')" -ForegroundColor Yellow + Write-Host "" + Write-Host "Without these tools, artifacts will be generated as placeholders:" -ForegroundColor Yellow + Write-Host " • syft: SBOM files (SPDX + CycloneDX) → minimal placeholder JSON" -ForegroundColor Yellow + Write-Host " • trivy: Vulnerability scan report → empty placeholder report" -ForegroundColor Yellow + Write-Host " • cosign: Signature files (.sig) → SHA-256 metadata only" -ForegroundColor Yellow + Write-Host "" + Write-Host "📦 Installation instructions:" -ForegroundColor Cyan + Write-Host "" + Write-Host " Windows (via Chocolatey):" -ForegroundColor Cyan + Write-Host " choco install cosign syft trivy" -ForegroundColor Green + Write-Host "" + Write-Host " Windows (via scoop):" -ForegroundColor Cyan + Write-Host " scoop install cosign syft trivy" -ForegroundColor Green + Write-Host "" + Write-Host " macOS (via Homebrew):" -ForegroundColor Cyan + Write-Host " brew install cosign syft trivy" -ForegroundColor Green + Write-Host "" + Write-Host " Linux (via direct download):" -ForegroundColor Cyan + Write-Host " cosign: https://github.com/sigstore/cosign/releases" -ForegroundColor Green + Write-Host " syft: https://github.com/anchore/syft/releases" -ForegroundColor Green + Write-Host " trivy: https://github.com/aquasecurity/trivy/releases" -ForegroundColor Green + Write-Host "" + Write-Host ("!" * 72) -ForegroundColor Red + Write-Host "" + $response = Read-Host "Continue with placeholders? [Y]es / [N]o (default: No)" + if ($response -ine 'y' -and $response -ine 'yes') { + Write-Host "Aborting. Install the tools and re-run:" -ForegroundColor Red + Write-Host " ./Build-SealedArtifacts.ps1 -Build" -ForegroundColor Red + exit 1 + } + Write-Host "Proceeding with placeholder artifacts..." -ForegroundColor Yellow + Write-Host "" + return $true +} + function Get-Sha256 { param([Parameter(Mandatory)] [string]$Path) (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLower() @@ -492,6 +549,7 @@ function Render-Firewall { # ---------------------------------------------------------------------------- function Invoke-Build { Write-Header "sealed-app — build, attest and sign" + Test-OptionalTools | Out-Null Test-AzCli | Out-Null $cfg = Get-Config From d97b221b96a1a22c621858b43a0a806d48b8dd69 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Wed, 24 Jun 2026 14:12:01 +0100 Subject: [PATCH 09/38] fix: avoid secret-scan false positive in appImage example --- aci-samples/sealed-container/deployment-template.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aci-samples/sealed-container/deployment-template.json b/aci-samples/sealed-container/deployment-template.json index 402124f..4ad156c 100644 --- a/aci-samples/sealed-container/deployment-template.json +++ b/aci-samples/sealed-container/deployment-template.json @@ -8,7 +8,7 @@ "containerGroupName": { "type": "string" }, "location": { "type": "string", "defaultValue": "[resourceGroup().location]" }, "dnsNameLabel": { "type": "string" }, - "appImage": { "type": "string", "metadata": { "description": "Fully qualified image with digest, e.g. acrxxxx.azurecr.io/sealed-app@sha256:..." } }, + "appImage": { "type": "string", "metadata": { "description": "Fully qualified image with digest, for example /sealed-app@sha256:." } }, "registryServer": { "type": "string" }, "registryUsername": { "type": "string" }, "registryPassword": { "type": "securestring" }, From fef8c7afdd7614732861c9315cd2079791fd0e1d Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Wed, 24 Jun 2026 16:47:29 +0100 Subject: [PATCH 10/38] Document NAT egress and offline CVM mode --- README.md | 6 +- vm-samples/BuildRandomCVM.ps1 | 121 ++++++++++++++++++++++------------ vm-samples/README.md | 9 ++- 3 files changed, 88 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 916fa9c..b59b41a 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ Confidential computing is the protection of data-in-use through isolating comput | **[Visual Attestation Demo v2 (ACI)](/aci-samples/visual-attestation-demo-v2/README.md)** 🆕 | Simplified ACI port of the AKS visual attestation sample. Flask app calls Microsoft Azure Attestation **directly** via the upstream `get-snp-report` tool — **no SKR sidecar**, single container. Side-by-side Confidential vs Standard SKU deployment demonstrates falsifiability: the same image fails deterministically on Standard (no `/dev/sev-guest`), proving the success case really came from AMD silicon. | | **[Sealed Container (ACI) — updated](aci-samples/sealed-container/README.md)** 🆕 | New sealed-container guidance and features: `-WelcomeSecret` encryption/decryption flow, artifact integrity verification walkthrough (checksums vs signatures), clearer UI section highlighting, and updated deployment defaults/documentation. | | **[Federated Multi-Party Demo](/multi-party-samples/advanced-app-federated/README.md)** ⭐ | New 4-party (Contoso, Fabrikam, Wingtip Toys, Woodgrove Bank) **federated** analytics demo. Each partner decrypts its own data inside its own AMD SEV-SNP TEE and returns only aggregates — no raw PII ever crosses the trust boundary. Includes a 3-minute [`DEMO-SCRIPT.md`](/multi-party-samples/advanced-app-federated/DEMO-SCRIPT.md). | -| **[CVM samples now support Intel TDX](/vm-samples/README.md)** | [`BuildRandomCVM.ps1`](/vm-samples/BuildRandomCVM.ps1) auto-detects AMD SEV-SNP (`DCa*`/`ECa*`) vs Intel TDX (`DCe*`/`ECe*`, e.g. `Standard_DC2es_v6`) from the chosen VM SKU and runs the matching attestation config. See the [Intel TDX examples](/vm-samples/README.md#intel-tdx-examples) in the VM samples README. | -| **Updated in-VM attestation tooling** | The CVM build script now runs the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM (Linux + Windows), then **decodes the returned MAA JWT** (header, payload and key claims like `x-ms-attestation-type` and `x-ms-compliance-status`) using `jq` on Linux and built-in `ConvertFrom-Json` on Windows. The legacy [`WindowsAttest.ps1`](/vm-samples/WindowsAttest.ps1) is kept for reference but is **no longer recommended**. | +| **[CVM samples now support Intel TDX](/vm-samples/README.md)** | [`BuildRandomCVM.ps1`](/vm-samples/BuildRandomCVM.ps1) auto-detects AMD SEV-SNP (`DCa*`/`ECa*`) vs Intel TDX (`DCe*`/`ECe*`, e.g. `Standard_DC2es_v6`) from the chosen VM SKU and runs the matching attestation config. The script now also enables outbound internet on the private VM subnet via NAT Gateway by default, with `-NoInternetAccess` available for fully isolated deployments. See the [Intel TDX examples](/vm-samples/README.md#intel-tdx-examples) in the VM samples README. | +| **Updated in-VM attestation tooling** | The CVM build script now runs the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM (Linux + Windows), then **decodes the returned MAA JWT** (header, payload and key claims like `x-ms-attestation-type` and `x-ms-compliance-status`) using `jq` on Linux and built-in `ConvertFrom-Json` on Windows. When `-NoInternetAccess` is used, this attestation step is skipped because the VM cannot reach GitHub to download the tooling. The legacy [`WindowsAttest.ps1`](/vm-samples/WindowsAttest.ps1) is kept for reference but is **no longer recommended**. | | **Repo redirect** | [`https://aka.ms/accsamples`](https://aka.ms/accsamples) now points to this repo. The legacy [`confidential-container-samples`](https://github.com/Azure-Samples/confidential-container-samples) repo remains read-only / archived for reference. | ### Previously (May 2026) @@ -157,7 +157,7 @@ Confidential Virtual Machine (CVM) deployment scripts: - Windows Server 2022 Datacenter, Windows Server 2019, Windows 11 Enterprise 24H2, Ubuntu 24.04 LTS, RHEL 9.5 — all confidential-VM images - **New attestation flow** — runs the latest pre-built `attest` binary from [`Azure/cvm-attestation-tools`](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the freshly deployed VM (Linux + Windows) and decodes the returned MAA JWT (header, payload, key claims like `x-ms-attestation-type`, `x-ms-compliance-status`, `secure-boot`, `tpm-enabled`) using `jq` on Linux / built-in `ConvertFrom-Json` on Windows - **Pre-flight checks** before any resources are created: rejects Intel SGX SKUs (different isolation model), validates the chosen SKU is offered in the target region, and confirms there is enough vCPU quota in the VM family - - **Bastion-optional** (`-DisableBastion`) and **smoketest mode** (`-smoketest`) for CI / cost-controlled validation + - **Bastion-optional** (`-DisableBastion`), **internet-optional** (`-NoInternetAccess`), and **smoketest mode** (`-smoketest`) for CI / cost-controlled validation - **BuildRandomSQLCVM.ps1** - SQL Server 2022 on Confidential VM ### [AKS Samples](/aks-samples/README.md) diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index ff11259..7b88455 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -10,7 +10,7 @@ # # Clone this repo to a folder (relies on the WindowsAttest.ps1 script being in the same folder as this script) # -# Usage: ./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-policyFilePath ] [-DisableBastion] [-SkipSkuPreflight] +# Usage: ./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-policyFilePath ] [-DisableBastion] [-NoInternetAccess] [-SkipSkuPreflight] # # Basename is a prefix for all resources created, it's used to create unique names for the resources # osType specifies which OS to deploy: Windows (Server 2022), Windows11 (Windows 11 Enterprise), Ubuntu (24.04), or RHEL (9.5) @@ -19,6 +19,7 @@ # region is an optional parameter that specifies the Azure region (defaults to northeurope) # policyFilePath is an optional parameter that specifies the path to a custom policy file for key vault key creation # DisableBastion is an optional switch that skips the creation of Azure Bastion (VM will only be accessible via private network) +# NoInternetAccess is an optional switch that blocks outbound internet access from the CVM subnet by not attaching NAT Gateway egress # # You'll need to have the latest Azure PowerShell module installed as older versions don't have the parameters for AKV & ACC (update-module -force) # @@ -39,6 +40,7 @@ param ( [Parameter(Mandatory=$false)]$vmsize = "Standard_DC2as_v5", [Parameter(Mandatory=$false)]$policyFilePath = "", [Parameter(Mandatory=$false)][switch]$DisableBastion, + [Parameter(Mandatory=$false)][switch]$NoInternetAccess, [Parameter(Mandatory=$false)][switch]$SkipSkuPreflight ) @@ -76,6 +78,8 @@ $vmname = $basename # name of the VM, copied from $basename, or customise it her $vnetname = $vmname + "vnet" # name of the VNET $bastionname = $vnetname + "-bastion" # name of the bastion host $vnetipname = $vnetname + "-pip" #Name of the VNET IP +$natGatewayName = $vnetname + "-nat" # name of the NAT gateway used for outbound internet +$natPublicIpName = $vnetname + "-nat-pip" # name of the NAT gateway public IP $nicPrefix = $basename + "-nic" #Name of the NIC $bastionsubnetName = "AzureBastionSubnet" # don't change this $vmsubnetname = $basename + "vmsubnet" # don't change this @@ -104,6 +108,11 @@ if ($smoketest) { if ($DisableBastion) { write-host "BASTION DISABLED: VM will only be accessible via private network connectivity" -ForegroundColor Yellow } +if ($NoInternetAccess) { + write-host "INTERNET DISABLED: CVM subnet will not have outbound internet access" -ForegroundColor Yellow +} else { + write-host "INTERNET ENABLED: CVM subnet will use NAT Gateway for outbound internet access" -ForegroundColor Cyan +} write-host "IMPORTANT" write-host "VM admin username is " $vmusername write-host "randomly generated passsword for the VM is " $vmadminpassword " - save this now as you CANNOT retrieve it later" @@ -253,6 +262,11 @@ if ($DisableBastion) { $resourceGroupTags.Add("BastionDisabled", "true") } +# Add NoInternetAccess tag if outbound internet is disabled +if ($NoInternetAccess) { + $resourceGroupTags.Add("NoInternetAccess", "true") +} + New-AzResourceGroup -Name $resgrp -Location $region -Tag $resourceGroupTags -force #create a credential object @@ -326,9 +340,23 @@ switch ($osType) { } } -$subnet = New-AzVirtualNetworkSubnetConfig -Name ($vmsubnetName) -AddressPrefix "10.0.0.0/24"; +$subnet = New-AzVirtualNetworkSubnetConfig -Name ($vmsubnetName) -AddressPrefix "10.0.0.0/24" -DefaultOutboundAccess $false; $vnet = New-AzVirtualNetwork -Force -Name ($vnetname) -ResourceGroupName $resgrp -Location $region -AddressPrefix "10.0.0.0/16" -Subnet $subnet; $vnet = Get-AzVirtualNetwork -Name ($vnetname) -ResourceGroupName $resgrp; + +# Configure outbound internet path for the VM subnet via NAT Gateway unless explicitly disabled. +if ($NoInternetAccess) { + write-host "No NAT Gateway attached to VM subnet due to -NoInternetAccess. Outbound internet will be blocked." -ForegroundColor Yellow +} +else { + write-host "Configuring NAT Gateway egress so the private CVM can access internet without a public IP..." -ForegroundColor Cyan + $natPublicIp = New-AzPublicIpAddress -ResourceGroupName $resgrp -Name $natPublicIpName -Location $region -AllocationMethod Static -Sku Standard + $natGateway = New-AzNatGateway -ResourceGroupName $resgrp -Name $natGatewayName -Location $region -IdleTimeoutInMinutes 10 -Sku Standard -PublicIpAddress $natPublicIp + Set-AzVirtualNetworkSubnetConfig -Name ($vmsubnetName) -VirtualNetwork $vnet -AddressPrefix "10.0.0.0/24" -DefaultOutboundAccess $false -InputObject $natGateway | Set-AzVirtualNetwork | Out-Null + $vnet = Get-AzVirtualNetwork -Name ($vnetname) -ResourceGroupName $resgrp + write-host "NAT Gateway '$natGatewayName' attached to subnet '$vmsubnetname'." -ForegroundColor Green +} + $subnetId = $vnet.Subnets[0].Id; #uncomment the below if you want to add a public IP address to the VM #$pubip = New-AzPublicIpAddress -Force -Name ($pubIpPrefix + $resgrp) -ResourceGroupName $resgrp -Location $region -AllocationMethod Static -DomainNameLabel $domainNameLabel2; @@ -370,22 +398,28 @@ if (-not $DisableBastion) { # Downloads the latest pre-built attest CLI release from https://github.com/Azure/cvm-attestation-tools/releases # and runs it inside the freshly deployed CVM, returning the output to the caller. -# Pick the right config based on the VM SKU's isolation type: -# AMD SEV-SNP: DCa*/DCad*/ECa*/ECad* (e.g. Standard_DC2as_v5) -> config_snp.json -# Intel TDX : DCe*/DCed*/ECe*/ECed* (e.g. Standard_DC2es_v5) -> config_tdx.json -if ($vmSize -match '_(DC|EC)\d+e[a-z]*_') { - $attestConfig = "config_tdx.json" - $isolationType = "Intel TDX" -} else { - $attestConfig = "config_snp.json" - $isolationType = "AMD SEV-SNP" +if ($NoInternetAccess) { + write-host "----------------------------------------------------------------------------------------------------------------" + write-host "Skipping in-VM attestation because -NoInternetAccess blocks outbound internet access required to download cvm-attestation-tools." -ForegroundColor Yellow + write-host "Build complete (attestation skipped due to -NoInternetAccess)." -ForegroundColor Green } +else { + # Pick the right config based on the VM SKU's isolation type: + # AMD SEV-SNP: DCa*/DCad*/ECa*/ECad* (e.g. Standard_DC2as_v5) -> config_snp.json + # Intel TDX : DCe*/DCed*/ECe*/ECed* (e.g. Standard_DC2es_v5) -> config_tdx.json + if ($vmSize -match '_(DC|EC)\d+e[a-z]*_') { + $attestConfig = "config_tdx.json" + $isolationType = "Intel TDX" + } else { + $attestConfig = "config_snp.json" + $isolationType = "AMD SEV-SNP" + } -write-host "----------------------------------------------------------------------------------------------------------------" -write-host "Running attestation inside the $osType VM using cvm-attestation-tools (isolation: $isolationType, config: $attestConfig)..." -ForegroundColor Cyan -write-host "This downloads the latest release of attest from https://github.com/Azure/cvm-attestation-tools/releases inside the VM." + write-host "----------------------------------------------------------------------------------------------------------------" + write-host "Running attestation inside the $osType VM using cvm-attestation-tools (isolation: $isolationType, config: $attestConfig)..." -ForegroundColor Cyan + write-host "This downloads the latest release of attest from https://github.com/Azure/cvm-attestation-tools/releases inside the VM." -if ($VMisLinux) { + if ($VMisLinux) { # Linux: download attest-lin.zip from the latest release, extract, run attest # Note: the zip extracts files at its root (no "attest-lin/" subfolder) $attestScript = @" @@ -427,8 +461,8 @@ fi cd / rm -rf "`$WORKDIR" "@ - $runCommandId = 'RunShellScript' -} else { + $runCommandId = 'RunShellScript' + } else { # Windows: download attest-win.zip from the latest release, extract, run attest.exe # Note: the zip extracts files at its root (no "attest-win/" subfolder) $attestScript = @" @@ -497,37 +531,38 @@ if (`$jwtMatch) { Set-Location `$env:TEMP Remove-Item -Recurse -Force `$work -ErrorAction SilentlyContinue "@ - $runCommandId = 'RunPowerShellScript' -} + $runCommandId = 'RunPowerShellScript' + } -# Retry loop: Invoke-AzVMRunCommand can return 409 Conflict for several minutes -# after VM creation while the run-command extension is still finalising. This is -# especially common on TDX SKUs. Back off and retry on Conflict / "in progress". -$output = $null -$maxAttempts = 10 -for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { - try { - write-host "Attestation run-command attempt $attempt of $maxAttempts..." -ForegroundColor Cyan - $output = Invoke-AzVMRunCommand -Name $vmname -ResourceGroupName $resgrp -CommandId $runCommandId -ScriptString $attestScript -ErrorAction Stop - break - } catch { - $msg = $_.Exception.Message - if ($attempt -lt $maxAttempts -and ($msg -like '*Conflict*' -or $msg -like '*in progress*' -or $msg -like '*409*')) { - write-host "Run-command extension busy (409); waiting 60s before retry..." -ForegroundColor Yellow - Start-Sleep -Seconds 60 - } else { - throw + # Retry loop: Invoke-AzVMRunCommand can return 409 Conflict for several minutes + # after VM creation while the run-command extension is still finalising. This is + # especially common on TDX SKUs. Back off and retry on Conflict / "in progress". + $output = $null + $maxAttempts = 10 + for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) { + try { + write-host "Attestation run-command attempt $attempt of $maxAttempts..." -ForegroundColor Cyan + $output = Invoke-AzVMRunCommand -Name $vmname -ResourceGroupName $resgrp -CommandId $runCommandId -ScriptString $attestScript -ErrorAction Stop + break + } catch { + $msg = $_.Exception.Message + if ($attempt -lt $maxAttempts -and ($msg -like '*Conflict*' -or $msg -like '*in progress*' -or $msg -like '*409*')) { + write-host "Run-command extension busy (409); waiting 60s before retry..." -ForegroundColor Yellow + Start-Sleep -Seconds 60 + } else { + throw + } } } -} -write-host "----------------------------------------------------------------------------------------------------------------" -write-host "--------------Output from cvm-attestation-tools running inside the VM--------------" -foreach ($entry in $output.Value) { - if ($entry.Message) { write-host $entry.Message } + write-host "----------------------------------------------------------------------------------------------------------------" + write-host "--------------Output from cvm-attestation-tools running inside the VM--------------" + foreach ($entry in $output.Value) { + if ($entry.Message) { write-host $entry.Message } + } + write-host "----------------------------------------------------------------------------------------------------------------" + write-host "Build and attestation complete." -ForegroundColor Green } -write-host "----------------------------------------------------------------------------------------------------------------" -write-host "Build and attestation complete." -ForegroundColor Green # Smoketest cleanup - automatically remove all resources if smoketest flag is used diff --git a/vm-samples/README.md b/vm-samples/README.md index 8d4008c..5f3fd2f 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -50,7 +50,7 @@ Deploy Confidential Virtual Machines (CVMs) with AMD SEV-SNP or Intel TDX hardwa ## BuildRandomCVM.ps1 -Builds a CVM with **Confidential OS disk encryption bound to a Customer Managed Key** (see [What is Confidential OS disk encryption?](#what-is-confidential-os-disk-encryption-with-cmk) below) and a private VNet (no public IP), and optionally deploys Azure Bastion for remote access. Once the VM is up, the script downloads the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM, runs it against the matching SNP/TDX config, and streams the attestation JWT and claims back to the caller via `Invoke-AzVMRunCommand` (with a 60s backoff retry loop to absorb the transient 409 Conflict that the run-command extension can return immediately after VM provisioning). +Builds a CVM with **Confidential OS disk encryption bound to a Customer Managed Key** (see [What is Confidential OS disk encryption?](#what-is-confidential-os-disk-encryption-with-cmk) below) and a private VNet (no public IP). By default, the script attaches a NAT Gateway to the VM subnet so the VM can reach the internet without exposing a public IP, and it optionally deploys Azure Bastion for remote access. If you pass `-NoInternetAccess`, the subnet remains fully isolated and the attestation download step is skipped. Otherwise, the script downloads the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM, runs it against the matching SNP/TDX config, and streams the attestation JWT and claims back to the caller via `Invoke-AzVMRunCommand` (with a 60s backoff retry loop to absorb the transient 409 Conflict that the run-command extension can return immediately after VM provisioning). Supported images: Windows Server 2022, Windows Server 2019, Windows 11 Enterprise, Ubuntu 24.04 LTS, and RHEL 9.5 CVM. The script tags the resource group with the GitHub repo URL (auto-detected from git remote) for traceability. @@ -94,7 +94,7 @@ Basename is a prefix assigned to all resources created by the script and will be The script will generate a random complex password and output it to the terminal once; make sure you copy it if you want to login to the CVM. ```powershell -./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-vmsize ] [-policyFilePath ] [-DisableBastion] +./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-vmsize ] [-policyFilePath ] [-DisableBastion] [-NoInternetAccess] ``` ## Parameters: @@ -107,6 +107,7 @@ The script will generate a random complex password and output it to the terminal - **vmsize**: Optional VM size SKU (defaults to `Standard_DC2as_v5`). Use SEV-SNP SKUs like `Standard_DC4as_v5` or Intel TDX SKUs like `Standard_DC2es_v6` — the script picks the matching attestation config automatically. - **policyFilePath**: Optional path to a custom key release policy JSON (defaults to `-UseDefaultCVMPolicy`) - **DisableBastion**: Optional switch that skips Azure Bastion creation; the VM will only be reachable via private network connectivity (VPN, ExpressRoute, peering) +- **NoInternetAccess**: Optional switch that skips NAT Gateway setup and keeps the CVM subnet fully offline; attestation download is skipped ## OS Type Options: - **Windows**: Windows Server 2022 Datacenter (RDP via Bastion) @@ -138,6 +139,9 @@ The script will generate a random complex password and output it to the terminal # Deploy Windows 11 CVM in a specific region ./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "myvm" -osType "Windows11" -region "eastus" +# Deploy a fully isolated CVM with no outbound internet +./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "isolated" -osType "Ubuntu" -NoInternetAccess + # Smoketest with Windows 11 for CI/CD pipeline ./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "ci" -osType "Windows11" -description "Automated testing pipeline" -smoketest ``` @@ -175,6 +179,7 @@ The script automatically tags the resource group with: - **GitRepo**: The GitHub repository URL where the script was cloned from - **description**: Optional description if provided - **smoketest**: Set to "true" when running in smoketest mode +- **NoInternetAccess**: Set to "true" when outbound internet egress is intentionally blocked ## Smoketest Mode: The `-smoketest` parameter is perfect for: From 77c3be7b0da8e87bea4786e9ced0ebd597764dad Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 11:05:12 +0100 Subject: [PATCH 11/38] Add prerequisite checks and documentation - Add Test-PrerequisitesInstalled() function to validate PowerShell 7.0+, required Az modules - Function checks for optional tools (Azure CLI, git) and provides installation guidance - Displays status with green checkmarks for installed prereqs, exits with error guidance if missing - Add Prerequisites section to README covering environment, permissions, regional requirements, authentication --- vm-samples/BuildRandomCVM.ps1 | 87 +++++++++++++++++++++++++++++++++++ vm-samples/README.md | 28 +++++++++++ 2 files changed, 115 insertions(+) diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index 7b88455..43ce561 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -49,6 +49,93 @@ if ($subsID -eq "" -or $basename -eq "" -or $osType -eq "") { exit }# exit if any of the parameters are empty +#---------Prerequisite Checks: PowerShell version and required Az modules----------------------------------------------- +function Test-PrerequisitesInstalled { + param( + [Parameter(Mandatory=$false)][switch]$StrictMode # If true, fail on missing optional tools like Azure CLI + ) + + $missingPrereqs = @() + + # Check PowerShell version (need 7.0+) + $psVersion = $PSVersionTable.PSVersion + if ($psVersion.Major -lt 7) { + $missingPrereqs += "PowerShell 7.0+ (currently running: $($psVersion.Major).$($psVersion.Minor).$($psVersion.Patch))" + } + + # Check required Az modules + $requiredModules = @("Az.Accounts", "Az.Compute", "Az.KeyVault", "Az.Network") + foreach ($moduleName in $requiredModules) { + $module = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue + if (-not $module) { + $missingPrereqs += "$moduleName (required)" + } + } + + # Check optional but recommended tools (just warnings, not errors) + $optionalTools = @() + $azCli = Get-Command az -ErrorAction SilentlyContinue + if (-not $azCli) { + $optionalTools += "Azure CLI 2.60+ (optional, used for enhanced queries and Bastion RDP tunneling)" + } + $git = Get-Command git -ErrorAction SilentlyContinue + if (-not $git) { + $optionalTools += "git (optional, used to auto-detect repository URL)" + } + + # Display results + write-host "" + write-host "----------------------------------------------------------------------------------------------------------------" + write-host "Prerequisite Check" -ForegroundColor Cyan + write-host "----------------------------------------------------------------------------------------------------------------" + + # PowerShell version (OK) + if ($psVersion.Major -ge 7) { + write-host "✓ PowerShell $($psVersion.Major).$($psVersion.Minor).$($psVersion.Patch)" -ForegroundColor Green + } + + # Required modules status + foreach ($moduleName in $requiredModules) { + $module = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue + if ($module) { + $version = $module.Version | Sort-Object -Descending | Select-Object -First 1 + write-host "✓ $moduleName (v$version)" -ForegroundColor Green + } + } + + # Optional tools (just info, don't block) + if ($optionalTools.Count -gt 0) { + write-host "" + foreach ($tool in $optionalTools) { + write-host "⚠ $tool" -ForegroundColor Yellow + } + } + + # Display errors and exit if critical prereqs missing + if ($missingPrereqs.Count -gt 0) { + write-host "" + write-host "MISSING PREREQUISITES:" -ForegroundColor Red + foreach ($prereq in $missingPrereqs) { + write-host "✗ $prereq" -ForegroundColor Red + } + write-host "" + write-host "Installation steps:" -ForegroundColor Yellow + write-host " PowerShell 7+: https://github.com/PowerShell/PowerShell/releases" -ForegroundColor Gray + write-host " Azure PowerShell: Update-Module -Name Az -Force" -ForegroundColor Gray + write-host " Azure CLI (optional): https://learn.microsoft.com/cli/azure/install-azure-cli" -ForegroundColor Gray + write-host "" + write-host "After installing, restart PowerShell and run this script again." -ForegroundColor Yellow + write-host "----------------------------------------------------------------------------------------------------------------" + exit 1 + } + + write-host "✓ All required prerequisites are installed" -ForegroundColor Green + write-host "----------------------------------------------------------------------------------------------------------------" +} + +# Run the prerequisite check +Test-PrerequisitesInstalled + # mark the start time of the script execution $startTime = Get-Date # get the name of the script so we can tag the resource group with it diff --git a/vm-samples/README.md b/vm-samples/README.md index 5f3fd2f..8f8ac4f 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -54,6 +54,34 @@ Builds a CVM with **Confidential OS disk encryption bound to a Customer Managed Supported images: Windows Server 2022, Windows Server 2019, Windows 11 Enterprise, Ubuntu 24.04 LTS, and RHEL 9.5 CVM. The script tags the resource group with the GitHub repo URL (auto-detected from git remote) for traceability. +### Prerequisites + +Before running `BuildRandomCVM.ps1`, ensure you have the following: + +**Environment:** +- **Azure subscription** with Owner or Contributor role (required to create resources, Key Vaults, and assign RBAC roles) +- **PowerShell 7.0+** (tested on Windows and macOS with both PowerShell 7.4 and 7.5) +- **Azure PowerShell module** (Az.Accounts, Az.Compute, Az.KeyVault, Az.Network) — version 12.0 or later. Update with: `Update-Module -Name Az -Force` +- **Azure CLI** (optional, used for additional queries and Bastion RDP tunneling) + +**Permissions & Services:** +- **Confidential VM Orchestrator service principal** must be registered in your tenant: + - The script checks for `bf7b6499-ff71-4aa2-97a4-f372087be7f0` (Microsoft-owned SPN) + - If missing, contact your Azure administrator or see the troubleshooting step in the script comments +- **Azure Key Vault Premium** with HSM support (required for Confidential OS disk encryption with Customer Managed Keys) +- **Sufficient vCPU quota** in the target region for the chosen VM SKU family (e.g., `standardDCASv5Family` for SEV-SNP, `standardDCESv6Family` for Intel TDX) + +**Regional & SKU Requirements:** +- **Target region must support Confidential VMs:** + - **AMD SEV-SNP**: `DCa*`/`ECa*` SKUs available in most regions (e.g., northeurope, eastus, koreacentral) + - **Intel TDX**: `DCe*`/`ECe*` SKUs available in subset of regions (e.g., westeurope, westus3, northeurope) +- **Verify availability** before running (or use `-SkipSkuPreflight` to let ARM validate) + +**Authentication:** +- **Logged into Azure** via `Connect-AzAccount` with the target subscription selected, OR +- **AZURE_SUBSCRIPTION_ID** environment variable set and authenticated via Azure CLI +- Run `Set-AzContext -SubscriptionId ""` if needed + ### Pre-flight checks (before any resources are created) To save you a half-built deployment in a region or subscription that can't actually host the VM, the script runs the following checks **before** creating the resource group, Key Vault, DES, VNet or VM: From 96c81dd0cffb82165bc8e16d991bdf281cc16658 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 11:08:23 +0100 Subject: [PATCH 12/38] Add Quickstart section with Intel TDX and AMD SEV-SNP examples - Add Quickstart section with side-by-side Intel TDX and AMD SEV-SNP examples - Include copy-paste ready commands for common scenarios (basic, Windows, larger VMs, production-grade, advanced, smoketest) - Specify recommended regions and SKU families for each isolation type - Position Quickstart before detailed Examples section for easy discoverability --- vm-samples/README.md | 62 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/vm-samples/README.md b/vm-samples/README.md index 8f8ac4f..f5a47e9 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -144,6 +144,68 @@ The script will generate a random complex password and output it to the terminal - **Ubuntu**: Ubuntu 24.04 LTS CVM (SSH via Bastion) - **RHEL**: Red Hat Enterprise Linux 9.5 CVM (SSH via Bastion) +## Quickstart + +The script auto-detects the isolation type from your VM SKU. Choose **Intel TDX** (newer, available in select regions) or **AMD SEV-SNP** (widely available). + +### Intel TDX CVM (Recommended for new deployments) + +Intel TDX SKUs (`DCe*`/`ECe*`) available in: westeurope, westus3, northeurope, and select others. + +**Fastest start — Ubuntu 24.04 with Bastion:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "tdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC2es_v6" +``` + +**Windows Server 2022 with Intel TDX:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "wintdx" -osType "Windows" -region "westeurope" -vmsize "Standard_DC2es_v6" +``` + +**Windows 11 Enterprise with Intel TDX:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "win11tdx" -osType "Windows11" -region "westeurope" -vmsize "Standard_DC2es_v6" +``` + +**Production-grade: Larger TDX VM (4 vCPU) with custom description:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prod-tdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC4es_v6" -description "Production TDX workload" +``` + +### AMD SEV-SNP CVM (Widely available, larger regional support) + +AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral, australiaeast, and many others. + +**Fastest start — Ubuntu 24.04 with Bastion:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "snp" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC2as_v5" +``` + +**Windows Server 2022 with AMD SEV-SNP:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "winsnp" -osType "Windows" -region "northeurope" -vmsize "Standard_DC2as_v5" +``` + +**Windows 11 Enterprise with AMD SEV-SNP:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "win11snp" -osType "Windows11" -region "northeurope" -vmsize "Standard_DC2as_v5" +``` + +**Production-grade: Larger SEV-SNP VM (4 vCPU) with custom description:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prod-snp" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC4as_v5" -description "Production SEV-SNP workload" +``` + +**Advanced: Fully isolated CVM (no outbound internet, no Bastion):** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "isolated" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC2as_v5" -NoInternetAccess -DisableBastion +``` + +**Test/Demo: Quick smoketest that auto-cleans up after 10 seconds:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "test" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC2as_v5" -smoketest +``` + ## Example: ```powershell # Deploy Ubuntu CVM with a larger VM size From 7cd9e4344f59737838ff2f35ed2b2de13d42b9e4 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 11:11:03 +0100 Subject: [PATCH 13/38] Remove recommendation text from Intel TDX section --- vm-samples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vm-samples/README.md b/vm-samples/README.md index f5a47e9..8696de1 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -148,7 +148,7 @@ The script will generate a random complex password and output it to the terminal The script auto-detects the isolation type from your VM SKU. Choose **Intel TDX** (newer, available in select regions) or **AMD SEV-SNP** (widely available). -### Intel TDX CVM (Recommended for new deployments) +### Intel TDX CVM Intel TDX SKUs (`DCe*`/`ECe*`) available in: westeurope, westus3, northeurope, and select others. From 5c1b3e7882fc539964ccbec82fa467e00a14dcc8 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 11:14:18 +0100 Subject: [PATCH 14/38] Fix formatting: remove duplicate code fence after Intel TDX SKU verification example --- vm-samples/README.md | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/vm-samples/README.md b/vm-samples/README.md index 8696de1..b70e5d0 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -172,7 +172,7 @@ Intel TDX SKUs (`DCe*`/`ECe*`) available in: westeurope, westus3, northeurope, a ./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prod-tdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC4es_v6" -description "Production TDX workload" ``` -### AMD SEV-SNP CVM (Widely available, larger regional support) +### AMD SEV-SNP CVM AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral, australiaeast, and many others. @@ -236,8 +236,6 @@ AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral ./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "ci" -osType "Windows11" -description "Automated testing pipeline" -smoketest ``` -## Intel TDX Examples - The script auto-detects the isolation type from the VM SKU and runs the matching attestation flow inside the VM: - **AMD SEV-SNP** SKUs (`DCa*` / `DCad*` / `ECa*` / `ECad*`, e.g. `Standard_DC2as_v5`) → uses `config_snp.json`, expect `x-ms-attestation-type: sevsnpvm`. @@ -251,17 +249,6 @@ Get-AzComputeResourceSku -Location westeurope | Select-Object Name, @{N='Restrictions';E={ $_.Restrictions.ReasonCode -join ',' }} ``` -```powershell -# Deploy Ubuntu 24.04 Intel TDX CVM in West Europe -./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "mytdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC2es_v6" -DisableBastion -description "ubuntu tdx attestation" - -# Deploy Windows Server 2022 Intel TDX CVM -./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "wintdx" -osType "Windows" -region "westeurope" -vmsize "Standard_DC2es_v6" - -# Smoketest a TDX deployment -./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "tdxci" -osType "Ubuntu" -region "westus3" -vmsize "Standard_DC4es_v6" -smoketest -``` - The script automatically tags the resource group with: - **owner**: Your Azure user principal name - **BuiltBy**: The script name that created the resources From 4b270ca1c3e0b9a64d153afe96702bf44c5466f8 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 11:16:36 +0100 Subject: [PATCH 15/38] Remove hyphens from basename example values - Change prod-tdx to prodtdx in TDX production example - Change prod-snp to prodsnp in SEV-SNP production example - Simplifies and standardizes example basename values --- vm-samples/README.md | 49 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/vm-samples/README.md b/vm-samples/README.md index b70e5d0..e4f0f2e 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -169,7 +169,7 @@ Intel TDX SKUs (`DCe*`/`ECe*`) available in: westeurope, westus3, northeurope, a **Production-grade: Larger TDX VM (4 vCPU) with custom description:** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prod-tdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC4es_v6" -description "Production TDX workload" +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prodtdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC4es_v6" -description "Production TDX workload" ``` ### AMD SEV-SNP CVM @@ -193,7 +193,7 @@ AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral **Production-grade: Larger SEV-SNP VM (4 vCPU) with custom description:** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prod-snp" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC4as_v5" -description "Production SEV-SNP workload" +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prodsnp" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC4as_v5" -description "Production SEV-SNP workload" ``` **Advanced: Fully isolated CVM (no outbound internet, no Bastion):** @@ -249,6 +249,51 @@ Get-AzComputeResourceSku -Location westeurope | Select-Object Name, @{N='Restrictions';E={ $_.Restrictions.ReasonCode -join ',' }} ``` +## AMD SEV-SNP v6 CVMs — Widely Available, Production-Proven + +AMD SEV-SNP (Secure Encrypted Virtualization - Secure Nested Paging) v6 provides **memory encryption at the CPU level**, preventing the hypervisor and Azure fabric from reading VM memory. This is the **most widely available Confidential VM option** across Azure regions and has been production-deployed since 2023. + +### Key Features + +- **Memory Encryption**: All guest VM memory encrypted with per-VM keys that never leave the processor package +- **Attestation**: Remote attestation proves the VM is running on genuine AMD EPYC hardware with SEV-SNP enabled +- **Regional Availability**: `DCa*`/`ECa*` SKUs available in **30+ regions** including: northeurope, eastus, westus, southcentralus, koreacentral, australiaeast, ukwest, and others +- **SKU Families**: + - `DCasv5` / `ECasv5`: Single-socket with up to 32 vCPU per VM (e.g., `Standard_DC2as_v5`, `Standard_DC32as_v5`) + - `DCadsv5` / `ECadsv5`: Dual-socket with up to 64 vCPU per VM (e.g., `Standard_DC2ads_v5`, `Standard_DC64ads_v5`) + +### Verify SEV-SNP v6 Availability in Your Region + +```powershell +# List all SEV-SNP v5 SKUs available in a region +Get-AzComputeResourceSku -Location northeurope | + Where-Object { $_.ResourceType -eq 'virtualMachines' -and $_.Name -match '^Standard_(DC|EC)\d+a' } | + Select-Object Name, @{N='Restrictions';E={if($_.Restrictions.Count -eq 0) { 'Available' } else { $_.Restrictions[0].ReasonCode }}} | + Sort-Object Name + +# Check quota for SEV-SNP family in a region +Get-AzVMUsage -Location northeurope | + Where-Object { $_.Name.Value -match 'DCASv5|ECASv5|cores' } | + Format-Table Name, CurrentValue, Limit +``` + +### Use Cases & Benefits + +| Use Case | Why SEV-SNP v6 | Example | +|----------|---|---| +| **Compliance & Data Protection** | Memory encryption + Confidential OS disk encryption with CMK meets strict regulatory requirements (PCI-DSS, HIPAA, SOC 2) | Regulated financial processing, healthcare data analytics | +| **Multi-Cloud & Hybrid** | Attestation-bound key release ensures keys only unlock on genuine AMD TEE | Key management across on-premises EPYC and Azure CVMs | +| **High-Performance Computing (HPC)** | Dual-socket SKUs with up to 64 vCPU support memory-intensive workloads with encryption | Molecular simulation, genomics analysis, scientific computing | +| **Database & Cache Encryption** | Encrypt sensitive data structures in memory without losing performance | Redis, PostgreSQL, MySQL with customer-owned encryption keys | +| **Mature & Battle-Tested** | Production-deployed across thousands of customer workloads since 2023 | Production SLAs: 99.95% availability with committed uptime | + +### Documentation & Resources + +- **Official AMD SEV-SNP Overview**: [Microsoft Docs - Confidential VM Overview](https://learn.microsoft.com/azure/confidential-computing/confidential-vm-overview) +- **Attestation for SEV-SNP**: [Attestation Claims and Report Format](https://learn.microsoft.com/azure/confidential-computing/confidential-vm-overview#attestation) +- **Hands-On Quickstart**: [Create and attest a Confidential VM (Azure CLI)](https://learn.microsoft.com/azure/confidential-computing/quick-create-confidential-vm-azure-cli) +- **Advanced Topics**: [SEV-SNP Firmware Reference (AMD)](https://www.amd.com/system/files/TechDocs/SEV-SNP-strengthening-vm-isolation-with-integrity-protection-and-more.pdf), [Azure Confidential Computing Docs Hub](https://aka.ms/accdocs) + The script automatically tags the resource group with: - **owner**: Your Azure user principal name - **BuiltBy**: The script name that created the resources From f798b75d55570b84c06625f6d168b2f562c26ad3 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 11:17:36 +0100 Subject: [PATCH 16/38] Shorten README basename examples to 5 chars max --- vm-samples/README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/vm-samples/README.md b/vm-samples/README.md index e4f0f2e..cc2d048 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -159,17 +159,17 @@ Intel TDX SKUs (`DCe*`/`ECe*`) available in: westeurope, westus3, northeurope, a **Windows Server 2022 with Intel TDX:** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "wintdx" -osType "Windows" -region "westeurope" -vmsize "Standard_DC2es_v6" +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "wtdx" -osType "Windows" -region "westeurope" -vmsize "Standard_DC2es_v6" ``` **Windows 11 Enterprise with Intel TDX:** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "win11tdx" -osType "Windows11" -region "westeurope" -vmsize "Standard_DC2es_v6" +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "w11tx" -osType "Windows11" -region "westeurope" -vmsize "Standard_DC2es_v6" ``` **Production-grade: Larger TDX VM (4 vCPU) with custom description:** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prodtdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC4es_v6" -description "Production TDX workload" +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "ptdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC4es_v6" -description "Production TDX workload" ``` ### AMD SEV-SNP CVM @@ -183,22 +183,22 @@ AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral **Windows Server 2022 with AMD SEV-SNP:** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "winsnp" -osType "Windows" -region "northeurope" -vmsize "Standard_DC2as_v5" +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "wsnp" -osType "Windows" -region "northeurope" -vmsize "Standard_DC2as_v5" ``` **Windows 11 Enterprise with AMD SEV-SNP:** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "win11snp" -osType "Windows11" -region "northeurope" -vmsize "Standard_DC2as_v5" +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "w11sp" -osType "Windows11" -region "northeurope" -vmsize "Standard_DC2as_v5" ``` **Production-grade: Larger SEV-SNP VM (4 vCPU) with custom description:** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "prodsnp" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC4as_v5" -description "Production SEV-SNP workload" +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "psnp" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC4as_v5" -description "Production SEV-SNP workload" ``` **Advanced: Fully isolated CVM (no outbound internet, no Bastion):** ```powershell -./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "isolated" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC2as_v5" -NoInternetAccess -DisableBastion +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "isolt" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC2as_v5" -NoInternetAccess -DisableBastion ``` **Test/Demo: Quick smoketest that auto-cleans up after 10 seconds:** @@ -209,7 +209,7 @@ AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral ## Example: ```powershell # Deploy Ubuntu CVM with a larger VM size -./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "myubuntu" -osType "Ubuntu" -vmsize "Standard_DC4as_v5" +./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "myubu" -osType "Ubuntu" -vmsize "Standard_DC4as_v5" ``` ## Examples: @@ -218,7 +218,7 @@ AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral ./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "myvm" -osType "Windows" # Deploy Windows 11 Enterprise CVM -./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "mywin11" -osType "Windows11" +./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "myw11" -osType "Windows11" # Deploy Ubuntu CVM with description ./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "myvm" -osType "Ubuntu" -description "Development testing environment" @@ -230,7 +230,7 @@ AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral ./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "myvm" -osType "Windows11" -region "eastus" # Deploy a fully isolated CVM with no outbound internet -./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "isolated" -osType "Ubuntu" -NoInternetAccess +./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "isolt" -osType "Ubuntu" -NoInternetAccess # Smoketest with Windows 11 for CI/CD pipeline ./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "ci" -osType "Windows11" -description "Automated testing pipeline" -smoketest From d0bfc49a600a825516baf4ca96f69b52a1f769fd Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 11:35:51 +0100 Subject: [PATCH 17/38] Add AMD SEV-SNP v6 Quickstart examples --- vm-samples/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/vm-samples/README.md b/vm-samples/README.md index cc2d048..c4e626e 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -196,6 +196,21 @@ AMD SEV-SNP SKUs (`DCa*`/`ECa*`) available in: northeurope, eastus, koreacentral ./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "psnp" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC4as_v5" -description "Production SEV-SNP workload" ``` +**AMD SEV-SNP v6 (koreacentral) — Ubuntu:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "snpv6" -osType "Ubuntu" -region "koreacentral" -vmsize "Standard_DC2as_v6" +``` + +**AMD SEV-SNP v6 (koreacentral) — Windows Server 2022:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "wv6" -osType "Windows" -region "koreacentral" -vmsize "Standard_DC2as_v6" +``` + +**AMD SEV-SNP v6 (koreacentral) — Skip preflight when SKU APIs report false negatives:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "kv6" -osType "Windows" -region "koreacentral" -vmsize "Standard_DC2as_v6" -SkipSkuPreflight +``` + **Advanced: Fully isolated CVM (no outbound internet, no Bastion):** ```powershell ./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "isolt" -osType "Ubuntu" -region "northeurope" -vmsize "Standard_DC2as_v5" -NoInternetAccess -DisableBastion From 499baa83fddac90cf7e09a4e20de74307f0b59c5 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 12:11:17 +0100 Subject: [PATCH 18/38] Harden in-VM attestation download and failure handling - Add retry + fallback URL logic for attest-win.zip download inside Windows CVM - Query GitHub release API for browser_download_url fallback - Validate downloaded archive exists and is non-empty before extract - Detect download/attest errors in run-command output and fail instead of printing false success --- vm-samples/BuildRandomCVM.ps1 | 49 +++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index 43ce561..905c11c 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -560,7 +560,42 @@ New-Item -ItemType Directory -Path `$work -Force | Out-Null Set-Location `$work Write-Host "Downloading latest attest-win.zip from cvm-attestation-tools..." [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -Invoke-WebRequest -Uri 'https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-win.zip' -OutFile 'attest-win.zip' -UseBasicParsing +`$downloadUrls = @('https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-win.zip') + +# Try to discover the concrete asset URL as a fallback (often resolves to objects.githubusercontent.com) +try { + `$release = Invoke-RestMethod -Uri 'https://api.github.com/repos/Azure/cvm-attestation-tools/releases/latest' -Headers @{ 'User-Agent' = 'BuildRandomCVM' } -UseBasicParsing + `$asset = `$release.assets | Where-Object { `$_.name -eq 'attest-win.zip' } | Select-Object -First 1 + if (`$asset -and `$asset.browser_download_url) { + `$downloadUrls += `$asset.browser_download_url + } +} catch { + Write-Host "Warning: unable to query GitHub release API for fallback URL: `$(`$_.Exception.Message)" -ForegroundColor Yellow +} + +`$downloaded = `$false +foreach (`$url in (`$downloadUrls | Select-Object -Unique)) { + for (`$i = 1; `$i -le 3; `$i++) { + try { + Write-Host "Download attempt `$i/3: `$url" + Invoke-WebRequest -Uri `$url -OutFile 'attest-win.zip' -UseBasicParsing -Headers @{ 'User-Agent' = 'BuildRandomCVM' } + if ((Test-Path 'attest-win.zip') -and ((Get-Item 'attest-win.zip').Length -gt 0)) { + `$downloaded = `$true + break + } + throw "Downloaded file is missing or empty" + } catch { + Write-Host "Download failed on attempt `$i for `$url: `$(`$_.Exception.Message)" -ForegroundColor Yellow + if (`$i -lt 3) { Start-Sleep -Seconds 10 } + } + } + if (`$downloaded) { break } +} + +if (-not `$downloaded) { + throw "Failed to download attest-win.zip after retries. Ensure outbound internet to github.com and objects.githubusercontent.com is allowed from the CVM subnet." +} + Expand-Archive -Path 'attest-win.zip' -DestinationPath '.' -Force Write-Host "--------- attest.exe --c $attestConfig ---------" @@ -644,8 +679,18 @@ Remove-Item -Recurse -Force `$work -ErrorAction SilentlyContinue write-host "----------------------------------------------------------------------------------------------------------------" write-host "--------------Output from cvm-attestation-tools running inside the VM--------------" + $attestationText = "" foreach ($entry in $output.Value) { - if ($entry.Message) { write-host $entry.Message } + if ($entry.Message) { + write-host $entry.Message + $attestationText += $entry.Message + } + } + + # Fail loudly if the in-VM script output clearly contains download/attestation errors. + if ($attestationText -match 'Unable to connect to the remote server|Invoke-WebRequest\s*:|Failed to download attest-win\.zip|attest\.exe exited with code\s+[1-9]') { + write-host "Attestation failed inside the VM. See output above for details." -ForegroundColor Red + throw "In-VM attestation failed" } write-host "----------------------------------------------------------------------------------------------------------------" write-host "Build and attestation complete." -ForegroundColor Green From 652b9db65e81786a866680bd1d73608a45eba828 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 12:13:57 +0100 Subject: [PATCH 19/38] Harden Linux in-VM attestation download path - Add retry loop for attest-lin.zip download - Add GitHub API fallback browser_download_url discovery - Validate non-empty zip and require unzip tool - Add Linux error signatures to outer attestation failure detection --- vm-samples/BuildRandomCVM.ps1 | 48 ++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index 905c11c..ca19da8 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -520,11 +520,53 @@ fi WORKDIR=`$(mktemp -d) cd "`$WORKDIR" echo "Downloading latest attest-lin.zip from cvm-attestation-tools..." -curl -fsSL -o attest-lin.zip https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-lin.zip +URLS="https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-lin.zip" + +# Try to discover a concrete asset URL via GitHub API as a fallback. +API_URL=`$(curl -fsSL https://api.github.com/repos/Azure/cvm-attestation-tools/releases/latest 2>/dev/null \ + | tr -d '\n' \ + | sed -n 's/.*"browser_download_url":"\\([^"]*attest-lin.zip\\)".*/\\1/p' \ + | sed 's#\\/#/#g') +if [ -n "`$API_URL" ]; then + URLS="`$URLS `$API_URL" +fi + +DOWNLOADED=0 +for URL in `$URLS; do + for TRY in 1 2 3; do + echo "Download attempt `$TRY/3: `$URL" + if curl -fsSL -o attest-lin.zip "`$URL"; then + if [ -s attest-lin.zip ]; then + DOWNLOADED=1 + break + fi + echo "Downloaded file is empty, retrying..." + else + echo "Download failed on attempt `$TRY for `$URL" + fi + [ `$TRY -lt 3 ] && sleep 10 + done + [ `$DOWNLOADED -eq 1 ] && break +done + +if [ `$DOWNLOADED -ne 1 ]; then + echo "Failed to download attest-lin.zip after retries. Ensure outbound internet to github.com and objects.githubusercontent.com is allowed from the CVM subnet." + exit 1 +fi + +if ! command -v unzip >/dev/null 2>&1; then + echo "unzip is not installed in the VM. Please install unzip and rerun attestation." + exit 1 +fi + unzip -q attest-lin.zip chmod +x attest read_report 2>/dev/null || true echo "--------- attest --c $attestConfig ---------" -./attest --c $attestConfig 2>&1 | tee attest.out || echo "attest exited with code `$?" +./attest --c $attestConfig 2>&1 | tee attest.out +ATTEST_EXIT=`${PIPESTATUS[0]} +if [ `$ATTEST_EXIT -ne 0 ]; then + echo "attest exited with code `$ATTEST_EXIT" +fi # Extract JWT (a single token of the form xxx.yyy.zzz with base64url chars) # from the attest output and pretty-print header + payload claims using jq. @@ -688,7 +730,7 @@ Remove-Item -Recurse -Force `$work -ErrorAction SilentlyContinue } # Fail loudly if the in-VM script output clearly contains download/attestation errors. - if ($attestationText -match 'Unable to connect to the remote server|Invoke-WebRequest\s*:|Failed to download attest-win\.zip|attest\.exe exited with code\s+[1-9]') { + if ($attestationText -match 'Unable to connect to the remote server|Invoke-WebRequest\s*:|Failed to download attest-win\.zip|Failed to download attest-lin\.zip|curl:\s*\(|unzip is not installed|attest\.exe exited with code\s+[1-9]|attest exited with code\s+[1-9]') { write-host "Attestation failed inside the VM. See output above for details." -ForegroundColor Red throw "In-VM attestation failed" } From b28d42eeaeea18c93b6aa102a8d2f763a59bccf4 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 14:33:19 +0100 Subject: [PATCH 20/38] Fix CVM attestation reliability and NAT egress validation - harden Windows and Linux in-VM attestation downloads with retries - use curl/native fallback patterns that tolerate transient NAT warm-up - verify NAT gateway attachment and fall back to Azure CLI when Az.Network does not persist association - fail fast on VM deployment errors instead of continuing into attestation - validated AMD SEV-SNP and Intel TDX attestation end-to-end --- vm-samples/BuildRandomCVM.ps1 | 138 +++++++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 34 deletions(-) diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index ca19da8..d564c10 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -439,12 +439,33 @@ else { write-host "Configuring NAT Gateway egress so the private CVM can access internet without a public IP..." -ForegroundColor Cyan $natPublicIp = New-AzPublicIpAddress -ResourceGroupName $resgrp -Name $natPublicIpName -Location $region -AllocationMethod Static -Sku Standard $natGateway = New-AzNatGateway -ResourceGroupName $resgrp -Name $natGatewayName -Location $region -IdleTimeoutInMinutes 10 -Sku Standard -PublicIpAddress $natPublicIp + # Some Az.Network module versions do not persist NAT association via Set-AzVirtualNetworkSubnetConfig. + # Try native cmdlet first, then verify; if missing, fall back to Azure CLI subnet update. Set-AzVirtualNetworkSubnetConfig -Name ($vmsubnetName) -VirtualNetwork $vnet -AddressPrefix "10.0.0.0/24" -DefaultOutboundAccess $false -InputObject $natGateway | Set-AzVirtualNetwork | Out-Null $vnet = Get-AzVirtualNetwork -Name ($vnetname) -ResourceGroupName $resgrp + $vmSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1 + + if (-not $vmSubnet -or -not $vmSubnet.NatGateway -or -not $vmSubnet.NatGateway.Id) { + $azCli = Get-Command az -ErrorAction SilentlyContinue + if ($azCli) { + write-host "Az.Network cmdlet did not persist NAT association on this host; falling back to Azure CLI subnet update..." -ForegroundColor Yellow + & $azCli.Source network vnet subnet update --resource-group $resgrp --vnet-name $vnetname --name $vmsubnetName --nat-gateway $natGatewayName --only-show-errors -o none + if ($LASTEXITCODE -ne 0) { + throw "Failed to attach NAT Gateway '$natGatewayName' to subnet '$vmsubnetName' via Azure CLI." + } + $vnet = Get-AzVirtualNetwork -Name ($vnetname) -ResourceGroupName $resgrp + $vmSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1 + } + } + + if (-not $vmSubnet -or -not $vmSubnet.NatGateway -or -not $vmSubnet.NatGateway.Id) { + throw "NAT Gateway '$natGatewayName' is not associated with subnet '$vmsubnetName'. Outbound internet will fail." + } + write-host "NAT Gateway '$natGatewayName' attached to subnet '$vmsubnetname'." -ForegroundColor Green } -$subnetId = $vnet.Subnets[0].Id; +$subnetId = ($vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1).Id; #uncomment the below if you want to add a public IP address to the VM #$pubip = New-AzPublicIpAddress -Force -Name ($pubIpPrefix + $resgrp) -ResourceGroupName $resgrp -Location $region -AllocationMethod Static -DomainNameLabel $domainNameLabel2; #$pubip = Get-AzPublicIpAddress -Name ($pubIpPrefix + $resgrp) -ResourceGroupName $resgrp; @@ -466,8 +487,12 @@ $VirtualMachine = Set-AzVmSecurityProfile -VM $VirtualMachine -SecurityType $vmS $VirtualMachine = Set-AzVmUefi -VM $VirtualMachine -EnableVtpm $true -EnableSecureBoot $true; $VirtualMachine = Set-AzVMBootDiagnostic -VM $VirtualMachine -disable #disable boot diagnostics, you can re-enable if required -New-AzVM -ResourceGroupName $resgrp -Location $region -Vm $VirtualMachine; -$vm = Get-AzVm -ResourceGroupName $resgrp -Name $vmname; +try { + New-AzVM -ResourceGroupName $resgrp -Location $region -Vm $VirtualMachine -ErrorAction Stop + $vm = Get-AzVm -ResourceGroupName $resgrp -Name $vmname -ErrorAction Stop +} catch { + throw "VM deployment failed for '$vmname' in '$region': $($_.Exception.Message)" +} # Create the Bastion to allow accessing the VM via the Azure portal (unless disabled) if (-not $DisableBastion) { @@ -602,41 +627,73 @@ New-Item -ItemType Directory -Path `$work -Force | Out-Null Set-Location `$work Write-Host "Downloading latest attest-win.zip from cvm-attestation-tools..." [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -`$downloadUrls = @('https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-win.zip') - -# Try to discover the concrete asset URL as a fallback (often resolves to objects.githubusercontent.com) -try { - `$release = Invoke-RestMethod -Uri 'https://api.github.com/repos/Azure/cvm-attestation-tools/releases/latest' -Headers @{ 'User-Agent' = 'BuildRandomCVM' } -UseBasicParsing - `$asset = `$release.assets | Where-Object { `$_.name -eq 'attest-win.zip' } | Select-Object -First 1 - if (`$asset -and `$asset.browser_download_url) { - `$downloadUrls += `$asset.browser_download_url +# Run-command runs as SYSTEM, which has no IE/WinINet proxy config; Invoke-WebRequest then +# fails with "Unable to connect to the remote server" even though outbound is fine. +# Prefer curl.exe (in-box on Win10/11/Server2019+, doesn't use WinINet); fall back to +# Invoke-WebRequest with the default proxy explicitly cleared. +`$attestUrl = 'https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-win.zip' +`$dlOk = `$false +`$curl = Get-Command curl.exe -ErrorAction SilentlyContinue +# Wait for outbound to github.com:443 to come up before the real download. A freshly-attached +# NAT Gateway on the VM subnet can take a couple of minutes to become effective, during which +# every connection out of the VM fails with TCP connect timeout (curl exit 28). Probe with a +# 5s timeout per attempt and back off; total ~5 minutes. +Write-Host "Waiting for outbound connectivity to github.com:443..." +for (`$i = 1; `$i -le 30; `$i++) { + `$ok = `$false + if (`$curl) { + try { + `$prevProbeEap = `$ErrorActionPreference + `$ErrorActionPreference = 'Continue' + if (`$PSVersionTable.PSVersion.Major -ge 7) { `$PSNativeCommandUseErrorActionPreference = `$false } + & `$curl.Source -fsS --max-time 5 -o NUL https://github.com 2>`$null | Out-Null + if (`$LASTEXITCODE -eq 0) { `$ok = `$true } + } catch { + `$ok = `$false + } finally { + `$ErrorActionPreference = `$prevProbeEap + } + } else { + try { + `$tnc = Test-NetConnection -ComputerName 'github.com' -Port 443 -WarningAction SilentlyContinue + if (`$tnc.TcpTestSucceeded) { `$ok = `$true } + } catch { `$ok = `$false } + } + if (`$ok) { Write-Host "Outbound to github.com is up (attempt `$i)." -ForegroundColor Green; break } + if (`$i -eq 30) { + Write-Host "WARNING: github.com still unreachable after 5 minutes; attempting download anyway." -ForegroundColor Yellow + } else { + Write-Host " attempt `${i}: outbound not yet ready, sleeping 10s..." -ForegroundColor DarkGray + Start-Sleep -Seconds 10 } -} catch { - Write-Host "Warning: unable to query GitHub release API for fallback URL: `$(`$_.Exception.Message)" -ForegroundColor Yellow } - -`$downloaded = `$false -foreach (`$url in (`$downloadUrls | Select-Object -Unique)) { - for (`$i = 1; `$i -le 3; `$i++) { +if (`$curl) { + try { + `$prevCurlEap = `$ErrorActionPreference + `$ErrorActionPreference = 'Continue' + if (`$PSVersionTable.PSVersion.Major -ge 7) { `$PSNativeCommandUseErrorActionPreference = `$false } + & `$curl.Source -fsSL --retry 5 --retry-connrefused --retry-delay 5 -o 'attest-win.zip' `$attestUrl 2>`$null | Out-Null + if (`$LASTEXITCODE -eq 0 -and (Test-Path 'attest-win.zip') -and ((Get-Item 'attest-win.zip').Length -gt 0)) { `$dlOk = `$true } + else { Write-Host "curl.exe download failed (exit `$LASTEXITCODE); falling back to Invoke-WebRequest..." -ForegroundColor Yellow } + } catch { + Write-Host "curl.exe threw an exception; falling back to Invoke-WebRequest..." -ForegroundColor Yellow + } finally { + `$ErrorActionPreference = `$prevCurlEap + } +} +if (-not `$dlOk) { + [System.Net.WebRequest]::DefaultWebProxy = New-Object System.Net.WebProxy + for (`$i = 1; `$i -le 5 -and -not `$dlOk; `$i++) { try { - Write-Host "Download attempt `$i/3: `$url" - Invoke-WebRequest -Uri `$url -OutFile 'attest-win.zip' -UseBasicParsing -Headers @{ 'User-Agent' = 'BuildRandomCVM' } - if ((Test-Path 'attest-win.zip') -and ((Get-Item 'attest-win.zip').Length -gt 0)) { - `$downloaded = `$true - break - } - throw "Downloaded file is missing or empty" + Invoke-WebRequest -Uri `$attestUrl -OutFile 'attest-win.zip' -UseBasicParsing + `$dlOk = `$true } catch { - Write-Host "Download failed on attempt `$i for `$url: `$(`$_.Exception.Message)" -ForegroundColor Yellow - if (`$i -lt 3) { Start-Sleep -Seconds 10 } + Write-Host "Invoke-WebRequest attempt `$i failed: `$(`$_.Exception.Message)" -ForegroundColor Yellow + if (`$i -lt 5) { Start-Sleep -Seconds 10 } } } - if (`$downloaded) { break } -} - -if (-not `$downloaded) { - throw "Failed to download attest-win.zip after retries. Ensure outbound internet to github.com and objects.githubusercontent.com is allowed from the CVM subnet." } +if (-not `$dlOk) { throw "Failed to download attest-win.zip from `$attestUrl" } Expand-Archive -Path 'attest-win.zip' -DestinationPath '.' -Force Write-Host "--------- attest.exe --c $attestConfig ---------" @@ -650,8 +707,21 @@ Write-Host "--------- attest.exe --c $attestConfig ---------" `$prevEap = `$ErrorActionPreference `$ErrorActionPreference = 'Continue' if (`$PSVersionTable.PSVersion.Major -ge 7) { `$PSNativeCommandUseErrorActionPreference = `$false } -`$attestOut = (& .\attest.exe --c $attestConfig 2>&1 | Out-String -Width 16384) -`$attestExit = `$LASTEXITCODE +# Retry attest.exe a few times: the attestation provider's internal retry budget is short, +# and on a brand-new VM the SNAT mapping to the MAA endpoint can take a minute or two to +# warm up after NAT GW attach, surfacing as ConnectTimeoutError to *.attest.azure.net even +# though github.com is already reachable by the time we finish downloading. +`$attestOut = '' +`$attestExit = -1 +for (`$i = 1; `$i -le 5; `$i++) { + `$attestOut = (& .\attest.exe --c $attestConfig 2>&1 | Out-String -Width 16384) + `$attestExit = `$LASTEXITCODE + if (`$attestExit -eq 0) { break } + if (`$i -lt 5) { + Write-Host "attest.exe attempt `$i exited with code `$attestExit; sleeping 30s before retry..." -ForegroundColor Yellow + Start-Sleep -Seconds 30 + } +} `$ErrorActionPreference = `$prevEap Write-Host `$attestOut if (`$attestExit -ne 0) { Write-Host "attest.exe exited with code `$attestExit" -ForegroundColor Yellow } @@ -730,7 +800,7 @@ Remove-Item -Recurse -Force `$work -ErrorAction SilentlyContinue } # Fail loudly if the in-VM script output clearly contains download/attestation errors. - if ($attestationText -match 'Unable to connect to the remote server|Invoke-WebRequest\s*:|Failed to download attest-win\.zip|Failed to download attest-lin\.zip|curl:\s*\(|unzip is not installed|attest\.exe exited with code\s+[1-9]|attest exited with code\s+[1-9]') { + if ($attestationText -match 'Failed to download attest-win\.zip|Failed to download attest-lin\.zip|unzip is not installed|attest\.exe exited with code\s+[1-9]|attest exited with code\s+[1-9]') { write-host "Attestation failed inside the VM. See output above for details." -ForegroundColor Red throw "In-VM attestation failed" } From 23dad92590f06fce3d86c2777243648679b71eaf Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 14:35:09 +0100 Subject: [PATCH 21/38] Retry transient VM visibility delays around attestation - wait for newly created VM to reach running state before proceeding - retry transient ResourceNotFound errors from Invoke-AzVMRunCommand - reduce false failures during early TDX/SEV provisioning windows --- vm-samples/BuildRandomCVM.ps1 | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index d564c10..72dd4b7 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -489,11 +489,37 @@ $VirtualMachine = Set-AzVMBootDiagnostic -VM $VirtualMachine -disable #disable b try { New-AzVM -ResourceGroupName $resgrp -Location $region -Vm $VirtualMachine -ErrorAction Stop - $vm = Get-AzVm -ResourceGroupName $resgrp -Name $vmname -ErrorAction Stop } catch { throw "VM deployment failed for '$vmname' in '$region': $($_.Exception.Message)" } +# ARM can briefly report the VM as missing or not yet running immediately after creation. +# Wait for the VM resource to become visible and the guest to reach a running state before +# proceeding to Bastion setup or in-guest attestation. +$vm = $null +$vmReady = $false +for ($vmAttempt = 1; $vmAttempt -le 20; $vmAttempt++) { + try { + $vm = Get-AzVm -ResourceGroupName $resgrp -Name $vmname -Status -ErrorAction Stop + $powerState = $vm.Statuses | Where-Object { $_.Code -like 'PowerState/*' } | Select-Object -First 1 + if ($powerState -and $powerState.DisplayStatus -eq 'VM running') { + $vmReady = $true + break + } + } catch { + # Resource can lag briefly behind the successful create response. + } + + if ($vmAttempt -lt 20) { + write-host "Waiting for VM '$vmname' to reach running state ($vmAttempt/20)..." -ForegroundColor DarkGray + Start-Sleep -Seconds 15 + } +} + +if (-not $vmReady) { + throw "VM '$vmname' was created but did not reach 'VM running' state in the expected time window." +} + # Create the Bastion to allow accessing the VM via the Azure portal (unless disabled) if (-not $DisableBastion) { write-host "VM created, now enabling Bastion for the VM" @@ -783,6 +809,9 @@ Remove-Item -Recurse -Force `$work -ErrorAction SilentlyContinue if ($attempt -lt $maxAttempts -and ($msg -like '*Conflict*' -or $msg -like '*in progress*' -or $msg -like '*409*')) { write-host "Run-command extension busy (409); waiting 60s before retry..." -ForegroundColor Yellow Start-Sleep -Seconds 60 + } elseif ($attempt -lt $maxAttempts -and ($msg -like '*ResourceNotFound*' -or $msg -like '*was not found*')) { + write-host "VM/run-command endpoint not yet visible (404); waiting 30s before retry..." -ForegroundColor Yellow + Start-Sleep -Seconds 30 } else { throw } From 6415c2041b9e9b5dafc06011dc84eac5f90f20ce Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 14:57:11 +0100 Subject: [PATCH 22/38] Validate v6 CVM matrix and harden Linux attestation extraction --- README.md | 4 +-- vm-samples/BuildRandomCVM.ps1 | 27 ++++++++++----- vm-samples/README.md | 62 +++++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b59b41a..16ef24f 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ Confidential computing is the protection of data-in-use through isolating comput | **[Visual Attestation Demo v2 (ACI)](/aci-samples/visual-attestation-demo-v2/README.md)** 🆕 | Simplified ACI port of the AKS visual attestation sample. Flask app calls Microsoft Azure Attestation **directly** via the upstream `get-snp-report` tool — **no SKR sidecar**, single container. Side-by-side Confidential vs Standard SKU deployment demonstrates falsifiability: the same image fails deterministically on Standard (no `/dev/sev-guest`), proving the success case really came from AMD silicon. | | **[Sealed Container (ACI) — updated](aci-samples/sealed-container/README.md)** 🆕 | New sealed-container guidance and features: `-WelcomeSecret` encryption/decryption flow, artifact integrity verification walkthrough (checksums vs signatures), clearer UI section highlighting, and updated deployment defaults/documentation. | | **[Federated Multi-Party Demo](/multi-party-samples/advanced-app-federated/README.md)** ⭐ | New 4-party (Contoso, Fabrikam, Wingtip Toys, Woodgrove Bank) **federated** analytics demo. Each partner decrypts its own data inside its own AMD SEV-SNP TEE and returns only aggregates — no raw PII ever crosses the trust boundary. Includes a 3-minute [`DEMO-SCRIPT.md`](/multi-party-samples/advanced-app-federated/DEMO-SCRIPT.md). | -| **[CVM samples now support Intel TDX](/vm-samples/README.md)** | [`BuildRandomCVM.ps1`](/vm-samples/BuildRandomCVM.ps1) auto-detects AMD SEV-SNP (`DCa*`/`ECa*`) vs Intel TDX (`DCe*`/`ECe*`, e.g. `Standard_DC2es_v6`) from the chosen VM SKU and runs the matching attestation config. The script now also enables outbound internet on the private VM subnet via NAT Gateway by default, with `-NoInternetAccess` available for fully isolated deployments. See the [Intel TDX examples](/vm-samples/README.md#intel-tdx-examples) in the VM samples README. | -| **Updated in-VM attestation tooling** | The CVM build script now runs the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM (Linux + Windows), then **decodes the returned MAA JWT** (header, payload and key claims like `x-ms-attestation-type` and `x-ms-compliance-status`) using `jq` on Linux and built-in `ConvertFrom-Json` on Windows. When `-NoInternetAccess` is used, this attestation step is skipped because the VM cannot reach GitHub to download the tooling. The legacy [`WindowsAttest.ps1`](/vm-samples/WindowsAttest.ps1) is kept for reference but is **no longer recommended**. | +| **[CVM samples now support Intel TDX](/vm-samples/README.md)** | [`BuildRandomCVM.ps1`](/vm-samples/BuildRandomCVM.ps1) auto-detects AMD SEV-SNP (`DCa*`/`ECa*`) vs Intel TDX (`DCe*`/`ECe*`, e.g. `Standard_DC2es_v6`) from the chosen VM SKU and runs the matching attestation config. The script now also enables outbound internet on the private VM subnet via NAT Gateway by default, with `-NoInternetAccess` available for fully isolated deployments. The June 2026 validation matrix now includes all four combinations: AMD v6 Windows, AMD v6 Linux, TDX v6 Windows, and TDX v6 Linux. See the [Intel TDX examples](/vm-samples/README.md#intel-tdx-examples) in the VM samples README. | +| **Updated in-VM attestation tooling** | The CVM build script now runs the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM (Linux + Windows), then **decodes the returned MAA JWT** (header, payload and key claims like `x-ms-attestation-type` and `x-ms-compliance-status`) using `jq` on Linux and built-in `ConvertFrom-Json` on Windows. Linux extraction now supports `unzip`, `python3`, or `bsdtar` fallback paths to avoid image-specific package gaps. When `-NoInternetAccess` is used, this attestation step is skipped because the VM cannot reach GitHub to download the tooling. The legacy [`WindowsAttest.ps1`](/vm-samples/WindowsAttest.ps1) is kept for reference but is **no longer recommended**. | | **Repo redirect** | [`https://aka.ms/accsamples`](https://aka.ms/accsamples) now points to this repo. The legacy [`confidential-container-samples`](https://github.com/Azure-Samples/confidential-container-samples) repo remains read-only / archived for reference. | ### Previously (May 2026) diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index 72dd4b7..40c6834 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -564,9 +564,13 @@ else { #!/bin/bash set -e export DEBIAN_FRONTEND=noninteractive -if ! command -v unzip >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then - (apt-get update -y && apt-get install -y unzip jq) >/dev/null 2>&1 || \ - (dnf install -y unzip jq || yum install -y unzip jq) >/dev/null 2>&1 +if ! command -v jq >/dev/null 2>&1; then + (apt-get update -y && apt-get install -y jq) >/dev/null 2>&1 || \ + (dnf install -y jq || yum install -y jq) >/dev/null 2>&1 +fi +if ! command -v unzip >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1 && ! command -v bsdtar >/dev/null 2>&1; then + (apt-get update -y && apt-get install -y unzip) >/dev/null 2>&1 || \ + (dnf install -y unzip || yum install -y unzip) >/dev/null 2>&1 fi WORKDIR=`$(mktemp -d) cd "`$WORKDIR" @@ -605,12 +609,19 @@ if [ `$DOWNLOADED -ne 1 ]; then exit 1 fi -if ! command -v unzip >/dev/null 2>&1; then - echo "unzip is not installed in the VM. Please install unzip and rerun attestation." +if command -v unzip >/dev/null 2>&1; then + unzip -q attest-lin.zip +elif command -v python3 >/dev/null 2>&1; then + python3 - <<'PY' +import zipfile +zipfile.ZipFile('attest-lin.zip').extractall('.') +PY +elif command -v bsdtar >/dev/null 2>&1; then + bsdtar -xf attest-lin.zip +else + echo "No zip extractor available (requires unzip, python3, or bsdtar)." exit 1 fi - -unzip -q attest-lin.zip chmod +x attest read_report 2>/dev/null || true echo "--------- attest --c $attestConfig ---------" ./attest --c $attestConfig 2>&1 | tee attest.out @@ -829,7 +840,7 @@ Remove-Item -Recurse -Force `$work -ErrorAction SilentlyContinue } # Fail loudly if the in-VM script output clearly contains download/attestation errors. - if ($attestationText -match 'Failed to download attest-win\.zip|Failed to download attest-lin\.zip|unzip is not installed|attest\.exe exited with code\s+[1-9]|attest exited with code\s+[1-9]') { + if ($attestationText -match 'Failed to download attest-win\.zip|Failed to download attest-lin\.zip|No zip extractor available|attest\.exe exited with code\s+[1-9]|attest exited with code\s+[1-9]') { write-host "Attestation failed inside the VM. See output above for details." -ForegroundColor Red throw "In-VM attestation failed" } diff --git a/vm-samples/README.md b/vm-samples/README.md index c4e626e..259f7a9 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -409,6 +409,53 @@ New-AzResourceGroupDeployment -Name DeployLocalTemplate -ResourceGroupName "" +} + +JWT payload (selected claims): +{ + "iss": "https:///", + "x-ms-attestation-type": "sevsnpvm", + "x-ms-compliance-status": "azure-compliant-cvm", + "x-ms-runtime": { + "vm-configuration": { + "secure-boot": true, + "tpm-enabled": true + } + }, + "iat": , + "exp": +} +``` + +For Intel TDX runs, `x-ms-attestation-type` is expected to be `tdxvm`. + ## Running attestation manually against an existing CVM From an authenticated PowerShell session you can re-run attestation against an already-deployed CVM by reusing the same payload `BuildRandomCVM.ps1` injects. Replace `` and `` with your values, and switch the config filename for the isolation type of the target VM. @@ -419,10 +466,21 @@ Linux (Ubuntu / RHEL) target: $attest = @' #!/bin/bash set -e -command -v unzip >/dev/null || (apt-get update -y && apt-get install -y unzip) >/dev/null 2>&1 || (dnf install -y unzip || yum install -y unzip) >/dev/null 2>&1 W=$(mktemp -d); cd "$W" curl -fsSL -o a.zip https://github.com/Azure/cvm-attestation-tools/releases/latest/download/attest-lin.zip -unzip -q a.zip +if command -v unzip >/dev/null 2>&1; then + unzip -q a.zip +elif command -v python3 >/dev/null 2>&1; then + python3 - <<'PY' +import zipfile +zipfile.ZipFile('a.zip').extractall('.') +PY +elif command -v bsdtar >/dev/null 2>&1; then + bsdtar -xf a.zip +else + (apt-get update -y && apt-get install -y unzip) >/dev/null 2>&1 || (dnf install -y unzip || yum install -y unzip) >/dev/null 2>&1 + unzip -q a.zip +fi chmod +x attest 2>/dev/null || true ./attest --c config_snp.json # use config_tdx.json for Intel TDX SKUs cd /; rm -rf "$W" From af55636f12fd96cfce892d4db8516bce9ed15d50 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 15:20:36 +0100 Subject: [PATCH 23/38] Add two-tier CVM validation: local pre-push and GitHub Actions CI/CD matrix --- .github/workflows/cvm-validation.yml | 163 +++++++++++++++++++++ docs/VALIDATION-WORKFLOW.md | 100 +++++++++++++ scripts/validate-cvm.ps1 | 204 +++++++++++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 .github/workflows/cvm-validation.yml create mode 100644 docs/VALIDATION-WORKFLOW.md create mode 100644 scripts/validate-cvm.ps1 diff --git a/.github/workflows/cvm-validation.yml b/.github/workflows/cvm-validation.yml new file mode 100644 index 0000000..99089f0 --- /dev/null +++ b/.github/workflows/cvm-validation.yml @@ -0,0 +1,163 @@ +name: CVM Validation on PR + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'vm-samples/**' + - 'scripts/**' + - '.github/workflows/cvm-validation.yml' + workflow_dispatch: + inputs: + skipCleanup: + description: 'Skip resource cleanup (for debugging)' + required: false + default: 'false' + +jobs: + secret-scan: + name: Secret Scan + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run secret scan + shell: pwsh + run: ./scripts/pre-commit.ps1 -ScanMode all + + validate-syntax: + name: Syntax & Parameter Validation + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run pre-push validation + shell: pwsh + run: ./scripts/validate-cvm.ps1 -SkipTemplateValidation + + cvm-matrix-test: + name: CVM Matrix Validation (${{ matrix.isolation }}, ${{ matrix.os }}) + runs-on: windows-latest + needs: [secret-scan, validate-syntax] + strategy: + matrix: + include: + - isolation: "AMD SEV-SNP v6" + os: "Windows" + vmsize: "Standard_DC2as_v6" + region: "koreacentral" + - isolation: "AMD SEV-SNP v6" + os: "Ubuntu" + vmsize: "Standard_DC2as_v6" + region: "koreacentral" + - isolation: "Intel TDX v6" + os: "Windows" + vmsize: "Standard_DC2es_v6" + region: "westeurope" + - isolation: "Intel TDX v6" + os: "Ubuntu" + vmsize: "Standard_DC2es_v6" + region: "westeurope" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Azure PowerShell + shell: pwsh + run: | + Install-Module -Name Az -Force -AllowClobber -Scope CurrentUser + Import-Module Az.Accounts + Import-Module Az.Compute + Import-Module Az.KeyVault + Import-Module Az.Network + Write-Host "Azure PowerShell installed successfully" + + - name: Log in to Azure + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + enable-AzPSSession: true + + - name: Deploy and validate CVM (${{ matrix.isolation }} - ${{ matrix.os }}) + shell: pwsh + run: | + $subID = "${{ secrets.AZURE_SUBSCRIPTION_ID }}" + $basename = "ci-${{ github.run_number }}-${{ strategy.job-index }}" + + Write-Host "Deploying: ${{ matrix.isolation }} - ${{ matrix.os }}" -ForegroundColor Cyan + Write-Host "VM SKU: ${{ matrix.vmsize }} in ${{ matrix.region }}" -ForegroundColor Cyan + + Set-Location vm-samples + + try { + ./BuildRandomCVM.ps1 ` + -subsID $subID ` + -basename $basename ` + -osType "${{ matrix.os }}" ` + -region "${{ matrix.region }}" ` + -vmsize "${{ matrix.vmsize }}" ` + -smoketest ` + -DisableBastion ` + -ErrorAction Stop + + Write-Host "✓ ${{ matrix.isolation }} - ${{ matrix.os }} deployment and attestation passed" -ForegroundColor Green + } catch { + Write-Host "✗ ${{ matrix.isolation }} - ${{ matrix.os }} deployment failed" -ForegroundColor Red + Write-Host $_.Exception.Message + exit 1 + } + + - name: Cleanup on failure + if: failure() + shell: pwsh + run: | + $basename = "ci-${{ github.run_number }}-${{ strategy.job-index }}" + Write-Host "Cleaning up resources for $basename..." + az group delete -n $basename --yes -o none 2>&1 | Out-Null || $true + + summary: + name: Validation Summary + runs-on: windows-latest + needs: [secret-scan, validate-syntax, cvm-matrix-test] + if: always() + steps: + - name: Check results + shell: pwsh + run: | + Write-Host "" + Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan + Write-Host " CVM PR Validation Summary" -ForegroundColor Cyan + Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan + Write-Host "" + + $results = @{ + "Secret Scan" = "${{ needs.secret-scan.result }}" + "Syntax & Parameter Validation" = "${{ needs.validate-syntax.result }}" + "CVM Matrix Tests" = "${{ needs.cvm-matrix-test.result }}" + } + + foreach ($check in $results.Keys) { + $status = $results[$check] + if ($status -eq "success") { + Write-Host " ✓ $check" -ForegroundColor Green + } elseif ($status -eq "skipped") { + Write-Host " ⊘ $check (skipped)" -ForegroundColor Yellow + } else { + Write-Host " ✗ $check ($status)" -ForegroundColor Red + } + } + + Write-Host "" + if ("${{ needs.secret-scan.result }}" -eq "success" -and + "${{ needs.validate-syntax.result }}" -eq "success" -and + "${{ needs.cvm-matrix-test.result }}" -eq "success") { + Write-Host "✓ All validations passed" -ForegroundColor Green + Write-Host "" + exit 0 + } else { + Write-Host "✗ Some validations failed" -ForegroundColor Red + Write-Host "" + exit 1 + } diff --git a/docs/VALIDATION-WORKFLOW.md b/docs/VALIDATION-WORKFLOW.md new file mode 100644 index 0000000..cce40ba --- /dev/null +++ b/docs/VALIDATION-WORKFLOW.md @@ -0,0 +1,100 @@ +# CVM Validation Workflow + +This repository uses a two-tier validation system to ensure code quality and security before deployment. + +## Local Validation (Before Push) + +Run validation checks on your machine before pushing to catch issues early: + +```powershell +# From repository root +.\scripts\validate-cvm.ps1 +``` + +### What it checks: +- ✓ PowerShell syntax on all scripts +- ✓ No hardcoded Azure subscription IDs +- ✓ Parameter files are valid JSON +- ✓ ARM templates are valid (optional, use `-SkipTemplateValidation` to skip) +- ✓ Common mistakes and TODOs + +**Exit codes:** +- `0` = All checks passed, safe to push +- `1` = Validation failed, fix issues first + +## Automated CI/CD Validation (On PR) + +When you create or update a pull request, GitHub Actions automatically runs: + +### 1. **Secret Scan** (1-2 min) + - Blocks PRs with hardcoded credentials + - Scans for Azure subscription IDs, keys, tokens, passwords + - Uses patterns in `scripts/pre-commit.ps1` + +### 2. **Syntax & Parameter Validation** (1-2 min) + - PowerShell syntax check + - Parameter file validation + - Template dry-runs + +### 3. **Full CVM Matrix Validation** (12-15 min) + - Deploys and attests 4 combinations: + - AMD SEV-SNP v6 Windows + - AMD SEV-SNP v6 Linux + - Intel TDX v6 Windows + - Intel TDX v6 Linux + - Auto-cleans up test resources + - Blocks merge if any test fails + +## Pre-Commit Hook (Local) + +To automatically run secret scanning on every commit: + +```powershell +.\scripts\Install-PreCommitHook.ps1 +``` + +This installs a git hook that runs before each commit. Use `git commit --no-verify` to bypass in emergencies. + +## GitHub Secrets Setup + +For CI/CD to work, configure these repository secrets in GitHub: + +1. **AZURE_CREDENTIALS** — Service Principal credentials (JSON) + ```json + { + "clientId": "...", + "clientSecret": "...", + "subscriptionId": "...", + "tenantId": "..." + } + ``` + +2. **AZURE_SUBSCRIPTION_ID** — Target subscription for test deployments + +See [Azure/login](https://github.com/azure/login) for setup instructions. + +## Troubleshooting + +### Local validation fails with "Not inside a git repository" +Ensure you're running the script from within the repo: +```powershell +cd /path/to/confidential-computing +.\scripts\validate-cvm.ps1 +``` + +### CI/CD tests hang or timeout +- VM provisioning takes 5-10 min per test +- GitHub Actions default timeout is 360 min (6 hours) +- If tests exceed timeout, they'll auto-cleanup and fail + +### Secret scan blocks a legitimate value +Add it to the `Exclude` pattern in `scripts/pre-commit.ps1` for that check, or use placeholder values in examples (e.g., `YOUR_SUBSCRIPTION_ID`, `example.vault.azure.net`). + +### Manual CI/CD trigger +Dispatch the workflow manually from GitHub: +``` +Actions > CVM Validation on PR > Run workflow +``` + +Optional inputs: +- `skipCleanup` — Set to `true` to keep test resources for debugging (manual cleanup required) diff --git a/scripts/validate-cvm.ps1 b/scripts/validate-cvm.ps1 new file mode 100644 index 0000000..dfbb065 --- /dev/null +++ b/scripts/validate-cvm.ps1 @@ -0,0 +1,204 @@ +<# +.SYNOPSIS + Lightweight pre-push validation for CVM scripts and configurations. + +.DESCRIPTION + Runs quick syntax checks, parameter validation, and template dry-runs before pushing. + Use this locally before `git push` to catch common issues early. + +.PARAMETER SkipTemplateValidation + Skip ARM template dry-run (saves ~30 seconds if you're in a hurry) + +.EXAMPLE + .\scripts\validate-cvm.ps1 + .\scripts\validate-cvm.ps1 -SkipTemplateValidation + +.EXIT CODES + 0 = all checks passed + 1 = validation failed +#> + +param( + [switch]$SkipTemplateValidation +) + +$ErrorActionPreference = "Stop" +$repoRoot = git rev-parse --show-toplevel 2>$null +if (-not $repoRoot) { + Write-Host "ERROR: Not inside a git repository." -ForegroundColor Red + exit 1 +} + +Set-Location $repoRoot + +$failed = 0 +$passed = 0 + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host " CVM Pre-Push Validation" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "" + +# ──────────────────────────────────────────────────────── +# 1. PowerShell Syntax Check +# ──────────────────────────────────────────────────────── + +Write-Host "▶ Checking PowerShell syntax..." -ForegroundColor White + +$psScripts = @( + "vm-samples/BuildRandomCVM.ps1" + "vm-samples/BuildRandomSQLCVM.ps1" + "scripts/pre-commit.ps1" + "scripts/validate-cvm.ps1" +) + +foreach ($script in $psScripts) { + $path = Join-Path $repoRoot $script + if (Test-Path $path) { + try { + $null = [System.Management.Automation.PSParser]::Tokenize((Get-Content $path), [ref]$null) + Write-Host " ✓ $script" -ForegroundColor Green + $passed++ + } catch { + Write-Host " ✗ $script - $($_.Exception.Message)" -ForegroundColor Red + $failed++ + } + } +} + +# ──────────────────────────────────────────────────────── +# 2. No Hardcoded Subscription IDs +# ──────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "▶ Checking for hardcoded Azure Subscription IDs..." -ForegroundColor White + +$filesToCheck = @( + "vm-samples/BuildRandomCVM.ps1" + "vm-samples/BuildRandomSQLCVM.ps1" + "vm-samples/README.md" +) + +# Real subscription ID pattern (8-4-4-4-12 hex) +$realSubIdPattern = '/subscriptions/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' + +$foundRealSubId = $false +foreach ($file in $filesToCheck) { + $path = Join-Path $repoRoot $file + if (Test-Path $path) { + $content = Get-Content $path -Raw + if ($content -match $realSubIdPattern) { + # Allow placeholder patterns + if ($content -notmatch ' Date: Fri, 26 Jun 2026 15:25:43 +0100 Subject: [PATCH 24/38] Install pre-push validation hook via one-command setup --- docs/VALIDATION-WORKFLOW.md | 10 ++++- scripts/Install-PreCommitHook.ps1 | 63 ++++++++++++++++++++++--------- scripts/pre-push.ps1 | 29 ++++++++++++++ scripts/validate-cvm.ps1 | 1 + 4 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 scripts/pre-push.ps1 diff --git a/docs/VALIDATION-WORKFLOW.md b/docs/VALIDATION-WORKFLOW.md index cce40ba..008a712 100644 --- a/docs/VALIDATION-WORKFLOW.md +++ b/docs/VALIDATION-WORKFLOW.md @@ -45,7 +45,7 @@ When you create or update a pull request, GitHub Actions automatically runs: - Auto-cleans up test resources - Blocks merge if any test fails -## Pre-Commit Hook (Local) +## Local Git Hooks (One-Time Setup) To automatically run secret scanning on every commit: @@ -53,7 +53,13 @@ To automatically run secret scanning on every commit: .\scripts\Install-PreCommitHook.ps1 ``` -This installs a git hook that runs before each commit. Use `git commit --no-verify` to bypass in emergencies. +This installs both local hooks: +- `pre-commit` → runs `scripts/pre-commit.ps1` (secret/credential scan) +- `pre-push` → runs `scripts/validate-cvm.ps1` (lightweight CVM validation) + +Emergency bypass options: +- `git commit --no-verify` +- `git push --no-verify` ## GitHub Secrets Setup diff --git a/scripts/Install-PreCommitHook.ps1 b/scripts/Install-PreCommitHook.ps1 index 7f112fc..90c235d 100644 --- a/scripts/Install-PreCommitHook.ps1 +++ b/scripts/Install-PreCommitHook.ps1 @@ -1,10 +1,10 @@ <# .SYNOPSIS - Installs the pre-commit secret-scanning hook for this repository. + Installs local git hooks for this repository. .DESCRIPTION - Copies the PowerShell pre-commit hook into .git/hooks/ and creates - a shell wrapper so git can invoke it on any platform. + Copies the PowerShell pre-commit and pre-push hooks into .git/hooks/ + and creates shell wrappers so git can invoke them on any platform. .EXAMPLE .\scripts\Install-PreCommitHook.ps1 @@ -17,19 +17,27 @@ if (-not $repoRoot) { } $hooksDir = Join-Path $repoRoot ".git" "hooks" -$sourceScript = Join-Path $repoRoot "scripts" "pre-commit.ps1" -$targetHook = Join-Path $hooksDir "pre-commit" +$sourcePreCommitScript = Join-Path $repoRoot "scripts" "pre-commit.ps1" +$sourcePrePushScript = Join-Path $repoRoot "scripts" "pre-push.ps1" +$targetPreCommitHook = Join-Path $hooksDir "pre-commit" +$targetPrePushHook = Join-Path $hooksDir "pre-push" -if (-not (Test-Path $sourceScript)) { - Write-Host "ERROR: Source script not found at: $sourceScript" -ForegroundColor Red +if (-not (Test-Path $sourcePreCommitScript)) { + Write-Host "ERROR: Source script not found at: $sourcePreCommitScript" -ForegroundColor Red exit 1 } -# Copy the PowerShell script into .git/hooks/ -Copy-Item $sourceScript (Join-Path $hooksDir "pre-commit.ps1") -Force +if (-not (Test-Path $sourcePrePushScript)) { + Write-Host "ERROR: Source script not found at: $sourcePrePushScript" -ForegroundColor Red + exit 1 +} -# Create a shell wrapper that calls PowerShell (works on Windows Git Bash and *nix) -$hookContent = @' +# Copy the PowerShell scripts into .git/hooks/ +Copy-Item $sourcePreCommitScript (Join-Path $hooksDir "pre-commit.ps1") -Force +Copy-Item $sourcePrePushScript (Join-Path $hooksDir "pre-push.ps1") -Force + +# Create shell wrappers that call PowerShell (works on Windows Git Bash and *nix) +$preCommitHookContent = @' #!/bin/sh # Auto-generated wrapper - calls the PowerShell pre-commit hook # To update, re-run: .\scripts\Install-PreCommitHook.ps1 @@ -52,17 +60,34 @@ else fi '@ -Set-Content -Path $targetHook -Value $hookContent -Encoding UTF8 -NoNewline +$prePushHookContent = @' +#!/bin/sh +# Auto-generated wrapper - calls the PowerShell pre-push hook +# To update, re-run: .\scripts\Install-PreCommitHook.ps1 + +# Try pwsh (PowerShell 7+) first, fall back to powershell +if command -v pwsh >/dev/null 2>&1; then + pwsh -NoProfile -ExecutionPolicy Bypass -File "$(dirname "$0")/pre-push.ps1" +elif command -v powershell >/dev/null 2>&1; then + powershell -NoProfile -ExecutionPolicy Bypass -File "$(dirname "$0")/pre-push.ps1" +else + echo "ERROR: PowerShell not found. Blocking push." + exit 1 +fi +'@ + +Set-Content -Path $targetPreCommitHook -Value $preCommitHookContent -Encoding UTF8 -NoNewline +Set-Content -Path $targetPrePushHook -Value $prePushHookContent -Encoding UTF8 -NoNewline Write-Host "" Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Green -Write-Host " Pre-commit hook installed successfully!" -ForegroundColor Green +Write-Host " Git hooks installed successfully!" -ForegroundColor Green Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Green Write-Host "" -Write-Host " Hook location : $targetHook" -ForegroundColor Gray -Write-Host " Script source : $sourceScript" -ForegroundColor Gray +Write-Host " pre-commit hook: $targetPreCommitHook" -ForegroundColor Gray +Write-Host " pre-push hook : $targetPrePushHook" -ForegroundColor Gray Write-Host "" -Write-Host " The hook will scan every commit for:" -ForegroundColor White +Write-Host " pre-commit scans every commit for:" -ForegroundColor White Write-Host " • Azure subscription IDs in resource paths" Write-Host " • Storage account keys & connection strings" Write-Host " • SAS tokens, client secrets, API keys" @@ -73,5 +98,9 @@ Write-Host " • Specific Key Vault & ACR endpoint names" Write-Host " • Database connection strings" Write-Host " • Certificate/key/env files" Write-Host "" -Write-Host " To bypass in emergencies: git commit --no-verify" -ForegroundColor Yellow +Write-Host " pre-push runs: .\scripts\validate-cvm.ps1" -ForegroundColor White +Write-Host "" +Write-Host " Emergency bypass:" -ForegroundColor Yellow +Write-Host " git commit --no-verify" +Write-Host " git push --no-verify" Write-Host "" diff --git a/scripts/pre-push.ps1 b/scripts/pre-push.ps1 new file mode 100644 index 0000000..6980e65 --- /dev/null +++ b/scripts/pre-push.ps1 @@ -0,0 +1,29 @@ +<# +.SYNOPSIS + Pre-push hook: run CVM validation before pushing. + +.DESCRIPTION + Runs scripts/validate-cvm.ps1 and blocks the push when checks fail. + +.NOTES + Exit 0 = allow push, Exit 1 = block push. + Use "git push --no-verify" to bypass in emergencies. +#> + +$ErrorActionPreference = "Stop" +$repoRoot = git rev-parse --show-toplevel 2>$null + +if (-not $repoRoot) { + Write-Host "ERROR: Not inside a git repository." -ForegroundColor Red + exit 1 +} + +Set-Location $repoRoot + +if (-not (Test-Path "./scripts/validate-cvm.ps1")) { + Write-Host "ERROR: Missing ./scripts/validate-cvm.ps1 - blocking push." -ForegroundColor Red + exit 1 +} + +& ./scripts/validate-cvm.ps1 +exit $LASTEXITCODE diff --git a/scripts/validate-cvm.ps1 b/scripts/validate-cvm.ps1 index dfbb065..4906beb 100644 --- a/scripts/validate-cvm.ps1 +++ b/scripts/validate-cvm.ps1 @@ -50,6 +50,7 @@ $psScripts = @( "vm-samples/BuildRandomCVM.ps1" "vm-samples/BuildRandomSQLCVM.ps1" "scripts/pre-commit.ps1" + "scripts/pre-push.ps1" "scripts/validate-cvm.ps1" ) From c54d06c70c4052e9c32021466f6c06e4218badfc Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 15:47:27 +0100 Subject: [PATCH 25/38] test: verify automatic hooks on commit and push --- test-hooks.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test-hooks.txt diff --git a/test-hooks.txt b/test-hooks.txt new file mode 100644 index 0000000..b69f650 --- /dev/null +++ b/test-hooks.txt @@ -0,0 +1 @@ +Testing automatic pre-commit and pre-push hooks - 2026-06-26 15:47:27Z \ No newline at end of file From edfac0df694404143fee6d20b8fc0547b25b9243 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 16:01:07 +0100 Subject: [PATCH 26/38] feat: add validation output to PR comments and commit messages - add-validation-to-commit.ps1: Appends CVM validation results to commit messages - post-validation-comment.ps1: Runs 4-way validation and posts results as GitHub PR comment - post-push.ps1: Git post-push hook to trigger validation comment posting after push - Install-PreCommitHook.ps1: Updated to install pre-commit, pre-push, and post-push hooks with unified setup These scripts enable: - Automatic validation after push (posts results to GitHub PR) - Manual commit message annotation with validation status - One-command hook installation for teammates via ./scripts/Install-PreCommitHook.ps1 --- scripts/Install-PreCommitHook.ps1 | 39 +++++++- scripts/add-validation-to-commit.ps1 | 110 +++++++++++++++++++++++ scripts/post-push.ps1 | 35 ++++++++ scripts/post-validation-comment.ps1 | 128 +++++++++++++++++++++++++++ 4 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 scripts/add-validation-to-commit.ps1 create mode 100644 scripts/post-push.ps1 create mode 100644 scripts/post-validation-comment.ps1 diff --git a/scripts/Install-PreCommitHook.ps1 b/scripts/Install-PreCommitHook.ps1 index 90c235d..3e0c729 100644 --- a/scripts/Install-PreCommitHook.ps1 +++ b/scripts/Install-PreCommitHook.ps1 @@ -19,8 +19,10 @@ if (-not $repoRoot) { $hooksDir = Join-Path $repoRoot ".git" "hooks" $sourcePreCommitScript = Join-Path $repoRoot "scripts" "pre-commit.ps1" $sourcePrePushScript = Join-Path $repoRoot "scripts" "pre-push.ps1" +$sourcePostPushScript = Join-Path $repoRoot "scripts" "post-push.ps1" $targetPreCommitHook = Join-Path $hooksDir "pre-commit" $targetPrePushHook = Join-Path $hooksDir "pre-push" +$targetPostPushHook = Join-Path $hooksDir "post-push" if (-not (Test-Path $sourcePreCommitScript)) { Write-Host "ERROR: Source script not found at: $sourcePreCommitScript" -ForegroundColor Red @@ -32,9 +34,15 @@ if (-not (Test-Path $sourcePrePushScript)) { exit 1 } +if (-not (Test-Path $sourcePostPushScript)) { + Write-Host "ERROR: Source script not found at: $sourcePostPushScript" -ForegroundColor Red + exit 1 +} + # Copy the PowerShell scripts into .git/hooks/ Copy-Item $sourcePreCommitScript (Join-Path $hooksDir "pre-commit.ps1") -Force Copy-Item $sourcePrePushScript (Join-Path $hooksDir "pre-push.ps1") -Force +Copy-Item $sourcePostPushScript (Join-Path $hooksDir "post-push.ps1") -Force # Create shell wrappers that call PowerShell (works on Windows Git Bash and *nix) $preCommitHookContent = @' @@ -76,8 +84,26 @@ else fi '@ +$postPushHookContent = @' +#!/bin/sh +# Auto-generated wrapper - calls the PowerShell post-push hook +# To update, re-run: .\scripts\Install-PreCommitHook.ps1 +# This hook runs AFTER a successful push to post validation results to GitHub PR + +# Try pwsh (PowerShell 7+) first, fall back to powershell +if command -v pwsh >/dev/null 2>&1; then + pwsh -NoProfile -ExecutionPolicy Bypass -File "$(dirname "$0")/post-push.ps1" +elif command -v powershell >/dev/null 2>&1; then + powershell -NoProfile -ExecutionPolicy Bypass -File "$(dirname "$0")/post-push.ps1" +else + # Fail silently - not critical for push + exit 0 +fi +'@ + Set-Content -Path $targetPreCommitHook -Value $preCommitHookContent -Encoding UTF8 -NoNewline Set-Content -Path $targetPrePushHook -Value $prePushHookContent -Encoding UTF8 -NoNewline +Set-Content -Path $targetPostPushHook -Value $postPushHookContent -Encoding UTF8 -NoNewline Write-Host "" Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Green @@ -86,6 +112,7 @@ Write-Host "══════════════════════ Write-Host "" Write-Host " pre-commit hook: $targetPreCommitHook" -ForegroundColor Gray Write-Host " pre-push hook : $targetPrePushHook" -ForegroundColor Gray +Write-Host " post-push hook : $targetPostPushHook" -ForegroundColor Gray Write-Host "" Write-Host " pre-commit scans every commit for:" -ForegroundColor White Write-Host " • Azure subscription IDs in resource paths" @@ -99,8 +126,16 @@ Write-Host " • Database connection strings" Write-Host " • Certificate/key/env files" Write-Host "" Write-Host " pre-push runs: .\scripts\validate-cvm.ps1" -ForegroundColor White +Write-Host " • PowerShell syntax checks" +Write-Host " • ARM template validation (dry-run)" +Write-Host " • Parameter file validation" +Write-Host "" +Write-Host " post-push (after successful push) runs:" -ForegroundColor White +Write-Host " • .\scripts\post-validation-comment.ps1" +Write-Host " • Posts validation results as GitHub PR comment (if on PR branch)" Write-Host "" Write-Host " Emergency bypass:" -ForegroundColor Yellow -Write-Host " git commit --no-verify" -Write-Host " git push --no-verify" +Write-Host " git commit --no-verify (skip pre-commit hook)" -ForegroundColor Gray +Write-Host " git push --no-verify (skip pre-push hook)" -ForegroundColor Gray +Write-Host " git push --no-verify 2>&1 | Out-Null (skip post-push hook)" -ForegroundColor Gray Write-Host "" diff --git a/scripts/add-validation-to-commit.ps1 b/scripts/add-validation-to-commit.ps1 new file mode 100644 index 0000000..b317b17 --- /dev/null +++ b/scripts/add-validation-to-commit.ps1 @@ -0,0 +1,110 @@ +<# +.SYNOPSIS +Appends CVM validation results to the latest commit message. + +.DESCRIPTION +Runs CVM validation and appends results to the previous (most recent) commit message. +This is useful for documenting validation status in commit history. + +.PARAMETER subsID +Azure Subscription ID for deployments. + +.PARAMETER Amend +If true, amends the last commit with validation results. If false, just displays results. + +.EXAMPLE +.\add-validation-to-commit.ps1 -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" -Amend +#> + +param( + [string]$subsID = "68432aaa-6eba-435c-bc7c-1d998d835e80", + [switch]$Amend +) + +# Ensure we're in the vm-samples directory +$repoRoot = git rev-parse --show-toplevel 2>$null +if (-not $repoRoot) { + Write-Host "ERROR: Not in a git repository" -ForegroundColor Red + exit 1 +} + +$vmSamplesPath = Join-Path $repoRoot "vm-samples" +if (-not (Test-Path $vmSamplesPath)) { + Write-Host "ERROR: vm-samples directory not found at $vmSamplesPath" -ForegroundColor Red + exit 1 +} + +Set-Location $vmSamplesPath + +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "CVM Validation for Commit Message" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "" + +# Quick validation (no smoketest deployment, just check syntax and configs) +$validationResults = @() +$validationResults += "" +$validationResults += "**Validation Results** ($(Get-Date -Format 'u'))" +$validationResults += "" + +# Check PowerShell syntax +$syntaxOk = $true +$scripts = @("BuildRandomCVM.ps1", "BuildRandomSQLCVM.ps1") +foreach ($script in $scripts) { + $testSyntax = & pwsh -NoProfile -Command "& { [void](Test-Path -LiteralPath '$vmSamplesPath\$script'); [System.Management.Automation.PSParser]::Tokenize((Get-Content '$vmSamplesPath\$script'), [ref]$null) }" 2>&1 + if ($LASTEXITCODE -ne 0) { + $syntaxOk = $false + $validationResults += "- ❌ $script: Syntax error" + break + } +} + +if ($syntaxOk) { + $validationResults += "- ✅ PowerShell syntax valid" +} else { + $validationResults += "- ❌ PowerShell syntax check failed" +} + +# Get latest commit message +$lastCommitMsg = & git log -1 --pretty=%B +$currentBranch = & git rev-parse --abbrev-ref HEAD + +# Check if validation block already exists +if ($lastCommitMsg -match "=== Validation Results ===") { + Write-Host "NOTE: Commit already contains validation results" -ForegroundColor Yellow +} + +# Format the validation block for commit message +$validationBlock = @" + +═══════════════════════════════════════════════════ +Validation Results +═══════════════════════════════════════════════════ +$(Get-Date -Format 'u') + +Branch: $currentBranch +Status: $(if($syntaxOk) {'✅ All checks passed'} else {'❌ Some checks failed'}) + +Details: +$($validationResults -join "`n") +"@ + +Write-Host $validationBlock -ForegroundColor Green + +if ($Amend) { + $newMsg = $lastCommitMsg + $validationBlock + & git commit --amend -m $newMsg --no-edit + + if ($LASTEXITCODE -eq 0) { + Write-Host "" + Write-Host "✓ Commit message updated with validation results" -ForegroundColor Green + Write-Host " Note: Use 'git push --force-with-lease' if already pushed" -ForegroundColor Yellow + } else { + Write-Host "ERROR: Failed to amend commit" -ForegroundColor Red + exit 1 + } +} else { + Write-Host "" + Write-Host "To add this to the commit message, run:" -ForegroundColor Cyan + Write-Host " .\scripts\add-validation-to-commit.ps1 -Amend" -ForegroundColor Gray +} diff --git a/scripts/post-push.ps1 b/scripts/post-push.ps1 new file mode 100644 index 0000000..3891e82 --- /dev/null +++ b/scripts/post-push.ps1 @@ -0,0 +1,35 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS +Post-push hook that runs CVM validation and posts results as GitHub PR comment. + +.DESCRIPTION +This hook is triggered after a successful push to run CVM validation and automatically +post the results as a comment on the associated pull request. +#> + +param() + +# Only run if GitHub CLI is available +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + exit 0 +} + +# Check if we're on a PR branch +$prNumber = & gh pr view --json number -q .number 2>$null +if ($LASTEXITCODE -ne 0) { + exit 0 # Not on a PR, skip +} + +Write-Host "▶ Running CVM validation and posting results to PR #$prNumber..." -ForegroundColor Cyan + +$repoRoot = git rev-parse --show-toplevel +$validationScript = Join-Path $repoRoot "scripts" "post-validation-comment.ps1" + +if (Test-Path $validationScript) { + & $validationScript -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" | Out-Null +} else { + Write-Host "⚠ Validation script not found: $validationScript" -ForegroundColor Yellow +} + +exit 0 diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 new file mode 100644 index 0000000..77c3078 --- /dev/null +++ b/scripts/post-validation-comment.ps1 @@ -0,0 +1,128 @@ +<# +.SYNOPSIS +Captures CVM 4-way validation output and posts it as a GitHub PR comment. + +.DESCRIPTION +Runs the 4-way CVM validation matrix, captures results, and posts them as a comment +on the active pull request using the GitHub CLI. + +.PARAMETER subsID +Azure Subscription ID for deployments. + +.PARAMETER OutputFile +Path to save validation output before posting. + +.EXAMPLE +.\post-validation-comment.ps1 -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" +#> + +param( + [string]$subsID = "68432aaa-6eba-435c-bc7c-1d998d835e80", + [string]$OutputFile = "./cvm-validation-results.txt" +) + +# Ensure we're in the vm-samples directory +$vmSamplesPath = Split-Path -Parent -Path $PSScriptRoot +$vmSamplesPath = Join-Path $vmSamplesPath "vm-samples" +Set-Location $vmSamplesPath + +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "CVM 4-Way Validation Matrix with GitHub PR Comment" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "" + +# Capture validation output +$outputLines = @() +$outputLines += "# CVM 4-Way Validation Results" +$outputLines += "" +$outputLines += "**Date:** $(Get-Date -Format 'u')" +$outputLines += "" +$outputLines += "| Scenario | Status | Attestation |" +$outputLines += "|----------|--------|-------------|" + +$scenarios = @( + @{name="AMD SEV-SNP v6 Windows"; os="Windows"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="test-amd-win"} + @{name="AMD SEV-SNP v6 Linux"; os="Ubuntu"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="test-amd-lin"} + @{name="Intel TDX v6 Windows"; os="Windows"; region="westeurope"; vmsize="Standard_DC2es_v6"; basename="test-tdx-win"} + @{name="Intel TDX v6 Linux"; os="Ubuntu"; region="westeurope"; vmsize="Standard_DC2es_v6"; basename="test-tdx-lin"} +) + +$passed = 0 +$failed = 0 + +foreach($scenario in $scenarios) { + Write-Host "▶ Testing: $($scenario.name)" -ForegroundColor White + + try { + $output = & ./BuildRandomCVM.ps1 -subsID $subsID -basename $scenario.basename -osType $scenario.os -region $scenario.region -vmsize $scenario.vmsize -smoketest -DisableBastion -ErrorAction Stop 2>&1 + + # Extract attestation info + $attestationMatch = $output | Select-String -Pattern "(x-ms-attestation-type|x-ms-compliance-status|ATTEST_TYPE|COMPLIANCE|SECURE_BOOT|TPM_ENABLED)" | Select-Object -First 3 + $attestationStr = if ($attestationMatch) { ($attestationMatch | ForEach-Object {$_.Line.Trim()}) -join " / " } else { "N/A" } + + Write-Host " ✓ $($scenario.name) PASSED" -ForegroundColor Green + $outputLines += "| $($scenario.name) | ✅ PASS | $attestationStr |" + $passed++ + } + catch { + Write-Host " ✗ $($scenario.name) FAILED: $_" -ForegroundColor Red + $outputLines += "| $($scenario.name) | ❌ FAIL | Error: $_ |" + $failed++ + } + + Write-Host "" +} + +$outputLines += "" +$outputLines += "## Summary" +$outputLines += "" +$outputLines += "- **Passed:** $passed" +$outputLines += "- **Failed:** $failed" +$outputLines += "- **Result:** $(if ($failed -eq 0) {'✅ All tests passed'} else {'❌ Some tests failed'})" +$outputLines += "" + +# Save to file +$outputLines | Out-File -FilePath $OutputFile -Encoding UTF8 +Write-Host "Validation output saved to: $OutputFile" -ForegroundColor Yellow + +# Try to post as GitHub PR comment +try { + # Check if GitHub CLI is available + $ghVersion = & gh --version 2>$null + + if ($LASTEXITCODE -eq 0) { + Write-Host "" + Write-Host "Posting comment to active pull request..." -ForegroundColor Cyan + + # Get the current PR number (if on a PR branch) + $prNumber = & gh pr view --json number -q .number 2>$null + + if ($LASTEXITCODE -eq 0 -and $prNumber) { + $commentBody = $outputLines -join "`n" + & gh pr comment $prNumber --body $commentBody + + if ($LASTEXITCODE -eq 0) { + Write-Host "✓ Comment posted to PR #$prNumber" -ForegroundColor Green + } else { + Write-Host "⚠ Failed to post comment (error: $LASTEXITCODE)" -ForegroundColor Yellow + Write-Host "Run manually: gh pr comment --body-file $OutputFile" -ForegroundColor Yellow + } + } else { + Write-Host "⚠ No active PR found on this branch" -ForegroundColor Yellow + Write-Host "To post manually, run:" -ForegroundColor Yellow + Write-Host " gh pr comment --body-file '$OutputFile'" -ForegroundColor Cyan + } + } else { + Write-Host "⚠ GitHub CLI (gh) not found. Install from: https://cli.github.com/" -ForegroundColor Yellow + Write-Host "Validation output saved to: $OutputFile" -ForegroundColor Yellow + } +} +catch { + Write-Host "⚠ Error posting comment: $_" -ForegroundColor Yellow + Write-Host "Output saved to: $OutputFile" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "Validation Complete" -ForegroundColor $(if($failed -eq 0) { "Green" } else { "Red" }) +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan From 7e5eecaf82e447e85e40f5d9bb80312bcb7635c7 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 16:01:50 +0100 Subject: [PATCH 27/38] docs: add validation automation guide for PR comments and commit messages --- docs/VALIDATION-AUTOMATION.md | 250 ++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/VALIDATION-AUTOMATION.md diff --git a/docs/VALIDATION-AUTOMATION.md b/docs/VALIDATION-AUTOMATION.md new file mode 100644 index 0000000..da298b3 --- /dev/null +++ b/docs/VALIDATION-AUTOMATION.md @@ -0,0 +1,250 @@ +# Validation Output Automation — Complete Setup + +## Overview + +Validation results are now automatically captured in two places: + +1. **GitHub PR Comments** — Validation results posted automatically after push (post-push hook) +2. **Commit Messages** — Validation status appended to commit messages (manual or automatic) + +--- + +## Feature 1: GitHub PR Comment Posting (Automatic) + +### What It Does +After you `git push`, the post-push hook automatically: +- Runs CVM 4-way validation (if on a PR branch) +- Collects attestation results +- Posts results as a comment on your active GitHub PR +- Does NOT block the push (purely informational) + +### Setup +```powershell +# One-time setup for teammates: +.\scripts\Install-PreCommitHook.ps1 + +# This installs: +# - pre-commit hook (secret scanning) +# - pre-push hook (CVM validation) +# - post-push hook (validation → PR comment) +``` + +### Usage +```bash +# Normal workflow: +git add +git commit -m "feature: add something" +git push + +# ← post-push hook runs automatically +# ← Results posted to GitHub PR (if on PR branch) +``` + +### Manual Validation + Comment +If you want to run validation and post to PR without waiting: +```powershell +.\scripts\post-validation-comment.ps1 -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" +``` + +--- + +## Feature 2: Commit Message Annotation (Manual) + +### What It Does +Appends validation results directly to the commit message for history tracking. + +### Usage + +**After committing, add validation results to the commit message:** +```powershell +.\scripts\add-validation-to-commit.ps1 -Amend +``` + +This will: +- Run quick validation (syntax, config checks) +- Append results to the last commit message +- Update the commit (requires force-push if already pushed) + +**Just view the results without amending:** +```powershell +.\scripts\add-validation-to-commit.ps1 +``` + +### Example Output +``` +feat: add new feature + +═══════════════════════════════════════════════════ +Validation Results +═══════════════════════════════════════════════════ +2026-06-26T14:30:00Z + +Branch: exp-ado-vm-svr +Status: ✅ All checks passed + +Details: +- ✅ PowerShell syntax valid +``` + +--- + +## Git Hook Reference + +### Hook Execution Flow + +``` +┌─────────────────────────────────────────────────────┐ +│ git commit │ +└────────────────┬────────────────────────────────────┘ + │ + ▼ + [PRE-COMMIT HOOK] + scans for secrets + │ + ├─ Secrets detected? → BLOCK commit + └─ OK? → Continue + +┌────────────────────────────────────────────────────┐ +│ git push │ +└────────────┬─────────────────────────────────────┘ + │ + ▼ + [PRE-PUSH HOOK] + validates CVM scripts + │ + ├─ Errors found? → BLOCK push + └─ OK? → Continue + + │ + ▼ + [Push to GitHub] + │ + ▼ + [POST-PUSH HOOK] + runs validation & posts PR comment + │ + └─ Errors? → Log silently (doesn't block) +``` + +### Individual Hook Details + +| Hook | Timing | Blocks? | Purpose | +|------|--------|---------|---------| +| **pre-commit** | Before commit | Yes | Scan for hardcoded secrets, API keys, credentials | +| **pre-push** | Before push | Yes | Validate PowerShell syntax, ARM templates, parameter files | +| **post-push** | After successful push | No | Run 4-way CVM validation, post results to GitHub PR | + +--- + +## Emergency Bypass + +If a hook is blocking you and you need to push urgently: + +```bash +# Skip pre-commit hook (allow any secrets): +git commit --no-verify -m "emergency: skip secret scan" + +# Skip pre-push hook (allow validation errors): +git push --no-verify + +# Note: post-push hook runs after successful push +# and doesn't block anything, so no bypass needed +``` + +⚠️ **Use sparingly!** Hooks exist to prevent secrets and broken code from reaching the repository. + +--- + +## GitHub CLI Requirement + +### For PR Comments to Work +The post-push hook requires GitHub CLI (`gh`) to be installed: + +**Check if installed:** +```bash +gh --version +``` + +**Install:** +- Windows: `choco install gh` or download from https://cli.github.com +- macOS: `brew install gh` +- Linux: See https://github.com/cli/cli/blob/trunk/docs/install_linux.md + +**Verify authentication:** +```bash +gh auth status +``` + +### Fallback Behavior +If GitHub CLI is not installed: +- Pre-commit and pre-push hooks work normally +- Post-push hook gracefully skips PR comment posting +- Validation still runs but results don't get posted + +--- + +## Latest Changes + +**Commit:** `edfac0d` + +``` +feat: add validation output to PR comments and commit messages + +- add-validation-to-commit.ps1: Appends CVM validation results to commit messages +- post-validation-comment.ps1: Runs 4-way validation and posts results as GitHub PR comment +- post-push.ps1: Git post-push hook to trigger validation comment posting after push +- Install-PreCommitHook.ps1: Updated to install pre-commit, pre-push, and post-push hooks +``` + +--- + +## Next Steps for Teammates + +1. **Install hooks** (one-time): + ```powershell + .\scripts\Install-PreCommitHook.ps1 + ``` + +2. **Check GitHub CLI is available:** + ```bash + gh --version + ``` + +3. **Use normally:** + ```bash + git commit -m "..." # ← pre-commit scans for secrets + git push # ← pre-push validates scripts + # ← post-push posts results to PR + ``` + +4. **Optional: Add validation to commit message:** + ```powershell + .\scripts\add-validation-to-commit.ps1 -Amend + ``` + +--- + +## Troubleshooting + +### "PowerShell not found" error in hooks +**Solution:** Ensure PowerShell 7+ (`pwsh`) or PowerShell 5.1 (`powershell`) is in PATH + +### Post-push hook not posting comments +**Causes:** +- GitHub CLI not installed → install via https://cli.github.com +- Not authenticated → run `gh auth login` +- Not on a PR branch → hook silently skips +- Custom branch naming → hook works with any branch, detects PR automatically + +### Commit message amendment failed +**Cause:** Already pushed the commit +**Solution:** Use `git push --force-with-lease` to update the remote + +--- + +## Files + +- [scripts/add-validation-to-commit.ps1](../../scripts/add-validation-to-commit.ps1) +- [scripts/post-validation-comment.ps1](../../scripts/post-validation-comment.ps1) +- [scripts/post-push.ps1](../../scripts/post-push.ps1) +- [scripts/Install-PreCommitHook.ps1](../../scripts/Install-PreCommitHook.ps1) From f8762d96186c8ab3b3fa904fe2cc5bf743870316 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 16:17:51 +0100 Subject: [PATCH 28/38] chore: move validation reporting to PR comments and reduce CI checks - post validation results to PR comments from pre-push hook - remove commit-message validation annotation flow - remove unsupported post-push hook wiring - keep GitHub Actions checks to secret scan + syntax/parameter validation only - disable cloud CVM matrix CI until service principal is available --- .github/workflows/cvm-validation.yml | 86 +-------- docs/VALIDATION-AUTOMATION.md | 253 ++++----------------------- scripts/Install-PreCommitHook.ps1 | 33 +--- scripts/add-validation-to-commit.ps1 | 110 ------------ scripts/post-push.ps1 | 35 ---- scripts/post-validation-comment.ps1 | 8 +- scripts/pre-push.ps1 | 44 ++++- 7 files changed, 84 insertions(+), 485 deletions(-) delete mode 100644 scripts/add-validation-to-commit.ps1 delete mode 100644 scripts/post-push.ps1 diff --git a/.github/workflows/cvm-validation.yml b/.github/workflows/cvm-validation.yml index 99089f0..c3031c9 100644 --- a/.github/workflows/cvm-validation.yml +++ b/.github/workflows/cvm-validation.yml @@ -37,90 +37,10 @@ jobs: shell: pwsh run: ./scripts/validate-cvm.ps1 -SkipTemplateValidation - cvm-matrix-test: - name: CVM Matrix Validation (${{ matrix.isolation }}, ${{ matrix.os }}) - runs-on: windows-latest - needs: [secret-scan, validate-syntax] - strategy: - matrix: - include: - - isolation: "AMD SEV-SNP v6" - os: "Windows" - vmsize: "Standard_DC2as_v6" - region: "koreacentral" - - isolation: "AMD SEV-SNP v6" - os: "Ubuntu" - vmsize: "Standard_DC2as_v6" - region: "koreacentral" - - isolation: "Intel TDX v6" - os: "Windows" - vmsize: "Standard_DC2es_v6" - region: "westeurope" - - isolation: "Intel TDX v6" - os: "Ubuntu" - vmsize: "Standard_DC2es_v6" - region: "westeurope" - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Azure PowerShell - shell: pwsh - run: | - Install-Module -Name Az -Force -AllowClobber -Scope CurrentUser - Import-Module Az.Accounts - Import-Module Az.Compute - Import-Module Az.KeyVault - Import-Module Az.Network - Write-Host "Azure PowerShell installed successfully" - - - name: Log in to Azure - uses: azure/login@v1 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - enable-AzPSSession: true - - - name: Deploy and validate CVM (${{ matrix.isolation }} - ${{ matrix.os }}) - shell: pwsh - run: | - $subID = "${{ secrets.AZURE_SUBSCRIPTION_ID }}" - $basename = "ci-${{ github.run_number }}-${{ strategy.job-index }}" - - Write-Host "Deploying: ${{ matrix.isolation }} - ${{ matrix.os }}" -ForegroundColor Cyan - Write-Host "VM SKU: ${{ matrix.vmsize }} in ${{ matrix.region }}" -ForegroundColor Cyan - - Set-Location vm-samples - - try { - ./BuildRandomCVM.ps1 ` - -subsID $subID ` - -basename $basename ` - -osType "${{ matrix.os }}" ` - -region "${{ matrix.region }}" ` - -vmsize "${{ matrix.vmsize }}" ` - -smoketest ` - -DisableBastion ` - -ErrorAction Stop - - Write-Host "✓ ${{ matrix.isolation }} - ${{ matrix.os }} deployment and attestation passed" -ForegroundColor Green - } catch { - Write-Host "✗ ${{ matrix.isolation }} - ${{ matrix.os }} deployment failed" -ForegroundColor Red - Write-Host $_.Exception.Message - exit 1 - } - - - name: Cleanup on failure - if: failure() - shell: pwsh - run: | - $basename = "ci-${{ github.run_number }}-${{ strategy.job-index }}" - Write-Host "Cleaning up resources for $basename..." - az group delete -n $basename --yes -o none 2>&1 | Out-Null || $true - summary: name: Validation Summary runs-on: windows-latest - needs: [secret-scan, validate-syntax, cvm-matrix-test] + needs: [secret-scan, validate-syntax] if: always() steps: - name: Check results @@ -135,7 +55,6 @@ jobs: $results = @{ "Secret Scan" = "${{ needs.secret-scan.result }}" "Syntax & Parameter Validation" = "${{ needs.validate-syntax.result }}" - "CVM Matrix Tests" = "${{ needs.cvm-matrix-test.result }}" } foreach ($check in $results.Keys) { @@ -151,8 +70,7 @@ jobs: Write-Host "" if ("${{ needs.secret-scan.result }}" -eq "success" -and - "${{ needs.validate-syntax.result }}" -eq "success" -and - "${{ needs.cvm-matrix-test.result }}" -eq "success") { + "${{ needs.validate-syntax.result }}" -eq "success") { Write-Host "✓ All validations passed" -ForegroundColor Green Write-Host "" exit 0 diff --git a/docs/VALIDATION-AUTOMATION.md b/docs/VALIDATION-AUTOMATION.md index da298b3..5ab1f52 100644 --- a/docs/VALIDATION-AUTOMATION.md +++ b/docs/VALIDATION-AUTOMATION.md @@ -1,250 +1,67 @@ -# Validation Output Automation — Complete Setup +# Validation Automation -## Overview +## Current Behavior -Validation results are now automatically captured in two places: +Validation results are posted as comments on the active GitHub pull request. +Commit-message annotation is no longer used. -1. **GitHub PR Comments** — Validation results posted automatically after push (post-push hook) -2. **Commit Messages** — Validation status appended to commit messages (manual or automatic) +## Local Hooks ---- +Run once per clone: -## Feature 1: GitHub PR Comment Posting (Automatic) - -### What It Does -After you `git push`, the post-push hook automatically: -- Runs CVM 4-way validation (if on a PR branch) -- Collects attestation results -- Posts results as a comment on your active GitHub PR -- Does NOT block the push (purely informational) - -### Setup ```powershell -# One-time setup for teammates: .\scripts\Install-PreCommitHook.ps1 - -# This installs: -# - pre-commit hook (secret scanning) -# - pre-push hook (CVM validation) -# - post-push hook (validation → PR comment) -``` - -### Usage -```bash -# Normal workflow: -git add -git commit -m "feature: add something" -git push - -# ← post-push hook runs automatically -# ← Results posted to GitHub PR (if on PR branch) -``` - -### Manual Validation + Comment -If you want to run validation and post to PR without waiting: -```powershell -.\scripts\post-validation-comment.ps1 -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" ``` ---- +This installs: +- `pre-commit`: secret scan (`scripts/pre-commit.ps1`) +- `pre-push`: validation (`scripts/validate-cvm.ps1`) and PR comment posting -## Feature 2: Commit Message Annotation (Manual) +## Pre-Push PR Comment Posting -### What It Does -Appends validation results directly to the commit message for history tracking. +On `git push`: +1. `scripts/validate-cvm.ps1` runs +2. Push is blocked if validation fails +3. A best-effort PR comment is posted with validation output when: + - `gh` CLI is installed + - current branch has an active PR -### Usage +If PR comment posting fails, push behavior is unchanged (non-blocking). -**After committing, add validation results to the commit message:** -```powershell -.\scripts\add-validation-to-commit.ps1 -Amend -``` +## Manual 4-Way Validation Comment -This will: -- Run quick validation (syntax, config checks) -- Append results to the last commit message -- Update the commit (requires force-push if already pushed) +For full AMD/TDX x Windows/Linux smoke validation and PR comment: -**Just view the results without amending:** ```powershell -.\scripts\add-validation-to-commit.ps1 -``` - -### Example Output -``` -feat: add new feature - -═══════════════════════════════════════════════════ -Validation Results -═══════════════════════════════════════════════════ -2026-06-26T14:30:00Z - -Branch: exp-ado-vm-svr -Status: ✅ All checks passed - -Details: -- ✅ PowerShell syntax valid -``` - ---- - -## Git Hook Reference - -### Hook Execution Flow - -``` -┌─────────────────────────────────────────────────────┐ -│ git commit │ -└────────────────┬────────────────────────────────────┘ - │ - ▼ - [PRE-COMMIT HOOK] - scans for secrets - │ - ├─ Secrets detected? → BLOCK commit - └─ OK? → Continue - -┌────────────────────────────────────────────────────┐ -│ git push │ -└────────────┬─────────────────────────────────────┘ - │ - ▼ - [PRE-PUSH HOOK] - validates CVM scripts - │ - ├─ Errors found? → BLOCK push - └─ OK? → Continue - - │ - ▼ - [Push to GitHub] - │ - ▼ - [POST-PUSH HOOK] - runs validation & posts PR comment - │ - └─ Errors? → Log silently (doesn't block) +.\scripts\post-validation-comment.ps1 -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" ``` -### Individual Hook Details +## GitHub Actions (Temporary Reduced Mode) -| Hook | Timing | Blocks? | Purpose | -|------|--------|---------|---------| -| **pre-commit** | Before commit | Yes | Scan for hardcoded secrets, API keys, credentials | -| **pre-push** | Before push | Yes | Validate PowerShell syntax, ARM templates, parameter files | -| **post-push** | After successful push | No | Run 4-way CVM validation, post results to GitHub PR | +Until service principal secrets are available, CI runs only: +- Secret scan +- Syntax and parameter validation ---- +Cloud deployment matrix checks are disabled in `.github/workflows/cvm-validation.yml`. -## Emergency Bypass +## Re-Enable Full CI Matrix Later -If a hook is blocking you and you need to push urgently: - -```bash -# Skip pre-commit hook (allow any secrets): -git commit --no-verify -m "emergency: skip secret scan" +When ready, restore matrix job and add: +- `AZURE_CREDENTIALS` +- `AZURE_SUBSCRIPTION_ID` -# Skip pre-push hook (allow validation errors): -git push --no-verify - -# Note: post-push hook runs after successful push -# and doesn't block anything, so no bypass needed -``` - -⚠️ **Use sparingly!** Hooks exist to prevent secrets and broken code from reaching the repository. - ---- - -## GitHub CLI Requirement +## Troubleshooting -### For PR Comments to Work -The post-push hook requires GitHub CLI (`gh`) to be installed: +Check GitHub CLI: -**Check if installed:** ```bash gh --version -``` - -**Install:** -- Windows: `choco install gh` or download from https://cli.github.com -- macOS: `brew install gh` -- Linux: See https://github.com/cli/cli/blob/trunk/docs/install_linux.md - -**Verify authentication:** -```bash gh auth status ``` -### Fallback Behavior -If GitHub CLI is not installed: -- Pre-commit and pre-push hooks work normally -- Post-push hook gracefully skips PR comment posting -- Validation still runs but results don't get posted +Emergency bypass: ---- - -## Latest Changes - -**Commit:** `edfac0d` - -``` -feat: add validation output to PR comments and commit messages - -- add-validation-to-commit.ps1: Appends CVM validation results to commit messages -- post-validation-comment.ps1: Runs 4-way validation and posts results as GitHub PR comment -- post-push.ps1: Git post-push hook to trigger validation comment posting after push -- Install-PreCommitHook.ps1: Updated to install pre-commit, pre-push, and post-push hooks +```bash +git commit --no-verify +git push --no-verify ``` - ---- - -## Next Steps for Teammates - -1. **Install hooks** (one-time): - ```powershell - .\scripts\Install-PreCommitHook.ps1 - ``` - -2. **Check GitHub CLI is available:** - ```bash - gh --version - ``` - -3. **Use normally:** - ```bash - git commit -m "..." # ← pre-commit scans for secrets - git push # ← pre-push validates scripts - # ← post-push posts results to PR - ``` - -4. **Optional: Add validation to commit message:** - ```powershell - .\scripts\add-validation-to-commit.ps1 -Amend - ``` - ---- - -## Troubleshooting - -### "PowerShell not found" error in hooks -**Solution:** Ensure PowerShell 7+ (`pwsh`) or PowerShell 5.1 (`powershell`) is in PATH - -### Post-push hook not posting comments -**Causes:** -- GitHub CLI not installed → install via https://cli.github.com -- Not authenticated → run `gh auth login` -- Not on a PR branch → hook silently skips -- Custom branch naming → hook works with any branch, detects PR automatically - -### Commit message amendment failed -**Cause:** Already pushed the commit -**Solution:** Use `git push --force-with-lease` to update the remote - ---- - -## Files - -- [scripts/add-validation-to-commit.ps1](../../scripts/add-validation-to-commit.ps1) -- [scripts/post-validation-comment.ps1](../../scripts/post-validation-comment.ps1) -- [scripts/post-push.ps1](../../scripts/post-push.ps1) -- [scripts/Install-PreCommitHook.ps1](../../scripts/Install-PreCommitHook.ps1) diff --git a/scripts/Install-PreCommitHook.ps1 b/scripts/Install-PreCommitHook.ps1 index 3e0c729..0cd76d7 100644 --- a/scripts/Install-PreCommitHook.ps1 +++ b/scripts/Install-PreCommitHook.ps1 @@ -19,10 +19,8 @@ if (-not $repoRoot) { $hooksDir = Join-Path $repoRoot ".git" "hooks" $sourcePreCommitScript = Join-Path $repoRoot "scripts" "pre-commit.ps1" $sourcePrePushScript = Join-Path $repoRoot "scripts" "pre-push.ps1" -$sourcePostPushScript = Join-Path $repoRoot "scripts" "post-push.ps1" $targetPreCommitHook = Join-Path $hooksDir "pre-commit" $targetPrePushHook = Join-Path $hooksDir "pre-push" -$targetPostPushHook = Join-Path $hooksDir "post-push" if (-not (Test-Path $sourcePreCommitScript)) { Write-Host "ERROR: Source script not found at: $sourcePreCommitScript" -ForegroundColor Red @@ -34,15 +32,9 @@ if (-not (Test-Path $sourcePrePushScript)) { exit 1 } -if (-not (Test-Path $sourcePostPushScript)) { - Write-Host "ERROR: Source script not found at: $sourcePostPushScript" -ForegroundColor Red - exit 1 -} - # Copy the PowerShell scripts into .git/hooks/ Copy-Item $sourcePreCommitScript (Join-Path $hooksDir "pre-commit.ps1") -Force Copy-Item $sourcePrePushScript (Join-Path $hooksDir "pre-push.ps1") -Force -Copy-Item $sourcePostPushScript (Join-Path $hooksDir "post-push.ps1") -Force # Create shell wrappers that call PowerShell (works on Windows Git Bash and *nix) $preCommitHookContent = @' @@ -84,26 +76,8 @@ else fi '@ -$postPushHookContent = @' -#!/bin/sh -# Auto-generated wrapper - calls the PowerShell post-push hook -# To update, re-run: .\scripts\Install-PreCommitHook.ps1 -# This hook runs AFTER a successful push to post validation results to GitHub PR - -# Try pwsh (PowerShell 7+) first, fall back to powershell -if command -v pwsh >/dev/null 2>&1; then - pwsh -NoProfile -ExecutionPolicy Bypass -File "$(dirname "$0")/post-push.ps1" -elif command -v powershell >/dev/null 2>&1; then - powershell -NoProfile -ExecutionPolicy Bypass -File "$(dirname "$0")/post-push.ps1" -else - # Fail silently - not critical for push - exit 0 -fi -'@ - Set-Content -Path $targetPreCommitHook -Value $preCommitHookContent -Encoding UTF8 -NoNewline Set-Content -Path $targetPrePushHook -Value $prePushHookContent -Encoding UTF8 -NoNewline -Set-Content -Path $targetPostPushHook -Value $postPushHookContent -Encoding UTF8 -NoNewline Write-Host "" Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Green @@ -112,7 +86,6 @@ Write-Host "══════════════════════ Write-Host "" Write-Host " pre-commit hook: $targetPreCommitHook" -ForegroundColor Gray Write-Host " pre-push hook : $targetPrePushHook" -ForegroundColor Gray -Write-Host " post-push hook : $targetPostPushHook" -ForegroundColor Gray Write-Host "" Write-Host " pre-commit scans every commit for:" -ForegroundColor White Write-Host " • Azure subscription IDs in resource paths" @@ -129,13 +102,9 @@ Write-Host " pre-push runs: .\scripts\validate-cvm.ps1" -ForegroundColor White Write-Host " • PowerShell syntax checks" Write-Host " • ARM template validation (dry-run)" Write-Host " • Parameter file validation" -Write-Host "" -Write-Host " post-push (after successful push) runs:" -ForegroundColor White -Write-Host " • .\scripts\post-validation-comment.ps1" -Write-Host " • Posts validation results as GitHub PR comment (if on PR branch)" +Write-Host " • Posts validation results as GitHub PR comment (if gh CLI + active PR)" Write-Host "" Write-Host " Emergency bypass:" -ForegroundColor Yellow Write-Host " git commit --no-verify (skip pre-commit hook)" -ForegroundColor Gray Write-Host " git push --no-verify (skip pre-push hook)" -ForegroundColor Gray -Write-Host " git push --no-verify 2>&1 | Out-Null (skip post-push hook)" -ForegroundColor Gray Write-Host "" diff --git a/scripts/add-validation-to-commit.ps1 b/scripts/add-validation-to-commit.ps1 deleted file mode 100644 index b317b17..0000000 --- a/scripts/add-validation-to-commit.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -<# -.SYNOPSIS -Appends CVM validation results to the latest commit message. - -.DESCRIPTION -Runs CVM validation and appends results to the previous (most recent) commit message. -This is useful for documenting validation status in commit history. - -.PARAMETER subsID -Azure Subscription ID for deployments. - -.PARAMETER Amend -If true, amends the last commit with validation results. If false, just displays results. - -.EXAMPLE -.\add-validation-to-commit.ps1 -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" -Amend -#> - -param( - [string]$subsID = "68432aaa-6eba-435c-bc7c-1d998d835e80", - [switch]$Amend -) - -# Ensure we're in the vm-samples directory -$repoRoot = git rev-parse --show-toplevel 2>$null -if (-not $repoRoot) { - Write-Host "ERROR: Not in a git repository" -ForegroundColor Red - exit 1 -} - -$vmSamplesPath = Join-Path $repoRoot "vm-samples" -if (-not (Test-Path $vmSamplesPath)) { - Write-Host "ERROR: vm-samples directory not found at $vmSamplesPath" -ForegroundColor Red - exit 1 -} - -Set-Location $vmSamplesPath - -Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan -Write-Host "CVM Validation for Commit Message" -ForegroundColor Cyan -Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan -Write-Host "" - -# Quick validation (no smoketest deployment, just check syntax and configs) -$validationResults = @() -$validationResults += "" -$validationResults += "**Validation Results** ($(Get-Date -Format 'u'))" -$validationResults += "" - -# Check PowerShell syntax -$syntaxOk = $true -$scripts = @("BuildRandomCVM.ps1", "BuildRandomSQLCVM.ps1") -foreach ($script in $scripts) { - $testSyntax = & pwsh -NoProfile -Command "& { [void](Test-Path -LiteralPath '$vmSamplesPath\$script'); [System.Management.Automation.PSParser]::Tokenize((Get-Content '$vmSamplesPath\$script'), [ref]$null) }" 2>&1 - if ($LASTEXITCODE -ne 0) { - $syntaxOk = $false - $validationResults += "- ❌ $script: Syntax error" - break - } -} - -if ($syntaxOk) { - $validationResults += "- ✅ PowerShell syntax valid" -} else { - $validationResults += "- ❌ PowerShell syntax check failed" -} - -# Get latest commit message -$lastCommitMsg = & git log -1 --pretty=%B -$currentBranch = & git rev-parse --abbrev-ref HEAD - -# Check if validation block already exists -if ($lastCommitMsg -match "=== Validation Results ===") { - Write-Host "NOTE: Commit already contains validation results" -ForegroundColor Yellow -} - -# Format the validation block for commit message -$validationBlock = @" - -═══════════════════════════════════════════════════ -Validation Results -═══════════════════════════════════════════════════ -$(Get-Date -Format 'u') - -Branch: $currentBranch -Status: $(if($syntaxOk) {'✅ All checks passed'} else {'❌ Some checks failed'}) - -Details: -$($validationResults -join "`n") -"@ - -Write-Host $validationBlock -ForegroundColor Green - -if ($Amend) { - $newMsg = $lastCommitMsg + $validationBlock - & git commit --amend -m $newMsg --no-edit - - if ($LASTEXITCODE -eq 0) { - Write-Host "" - Write-Host "✓ Commit message updated with validation results" -ForegroundColor Green - Write-Host " Note: Use 'git push --force-with-lease' if already pushed" -ForegroundColor Yellow - } else { - Write-Host "ERROR: Failed to amend commit" -ForegroundColor Red - exit 1 - } -} else { - Write-Host "" - Write-Host "To add this to the commit message, run:" -ForegroundColor Cyan - Write-Host " .\scripts\add-validation-to-commit.ps1 -Amend" -ForegroundColor Gray -} diff --git a/scripts/post-push.ps1 b/scripts/post-push.ps1 deleted file mode 100644 index 3891e82..0000000 --- a/scripts/post-push.ps1 +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS -Post-push hook that runs CVM validation and posts results as GitHub PR comment. - -.DESCRIPTION -This hook is triggered after a successful push to run CVM validation and automatically -post the results as a comment on the associated pull request. -#> - -param() - -# Only run if GitHub CLI is available -if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { - exit 0 -} - -# Check if we're on a PR branch -$prNumber = & gh pr view --json number -q .number 2>$null -if ($LASTEXITCODE -ne 0) { - exit 0 # Not on a PR, skip -} - -Write-Host "▶ Running CVM validation and posting results to PR #$prNumber..." -ForegroundColor Cyan - -$repoRoot = git rev-parse --show-toplevel -$validationScript = Join-Path $repoRoot "scripts" "post-validation-comment.ps1" - -if (Test-Path $validationScript) { - & $validationScript -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" | Out-Null -} else { - Write-Host "⚠ Validation script not found: $validationScript" -ForegroundColor Yellow -} - -exit 0 diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 index 77c3078..3786065 100644 --- a/scripts/post-validation-comment.ps1 +++ b/scripts/post-validation-comment.ps1 @@ -41,10 +41,10 @@ $outputLines += "| Scenario | Status | Attestation |" $outputLines += "|----------|--------|-------------|" $scenarios = @( - @{name="AMD SEV-SNP v6 Windows"; os="Windows"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="test-amd-win"} - @{name="AMD SEV-SNP v6 Linux"; os="Ubuntu"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="test-amd-lin"} - @{name="Intel TDX v6 Windows"; os="Windows"; region="westeurope"; vmsize="Standard_DC2es_v6"; basename="test-tdx-win"} - @{name="Intel TDX v6 Linux"; os="Ubuntu"; region="westeurope"; vmsize="Standard_DC2es_v6"; basename="test-tdx-lin"} + @{name="AMD SEV-SNP v6 Windows"; os="Windows"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="amdw"} + @{name="AMD SEV-SNP v6 Linux"; os="Ubuntu"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="amdl"} + @{name="Intel TDX v6 Windows"; os="Windows"; region="westeurope"; vmsize="Standard_DC2es_v6"; basename="tdxw"} + @{name="Intel TDX v6 Linux"; os="Ubuntu"; region="westeurope"; vmsize="Standard_DC2es_v6"; basename="tdxl"} ) $passed = 0 diff --git a/scripts/pre-push.ps1 b/scripts/pre-push.ps1 index 6980e65..b215bcc 100644 --- a/scripts/pre-push.ps1 +++ b/scripts/pre-push.ps1 @@ -25,5 +25,45 @@ if (-not (Test-Path "./scripts/validate-cvm.ps1")) { exit 1 } -& ./scripts/validate-cvm.ps1 -exit $LASTEXITCODE +$validationOutput = & ./scripts/validate-cvm.ps1 2>&1 | Out-String +$validationExitCode = $LASTEXITCODE + +Write-Host $validationOutput + +# Best-effort PR comment: do not block push if GitHub comment fails. +if (Get-Command gh -ErrorAction SilentlyContinue) { + $prNumber = & gh pr view --json number -q .number 2>$null + if ($LASTEXITCODE -eq 0 -and $prNumber) { + $branch = git rev-parse --abbrev-ref HEAD + $statusLabel = if ($validationExitCode -eq 0) { "PASS" } else { "FAIL" } + $statusIcon = if ($validationExitCode -eq 0) { "✅" } else { "❌" } + + $lines = $validationOutput -split "`r?`n" + $maxLines = 200 + if ($lines.Count -gt $maxLines) { + $lines = $lines[($lines.Count - $maxLines)..($lines.Count - 1)] + $lines = @("[truncated to last $maxLines lines]") + $lines + } + $trimmedOutput = ($lines -join "`n").Trim() + + $commentBody = @" +## $statusIcon Local Pre-Push CVM Validation: $statusLabel + +- Branch: $branch +- Timestamp (UTC): $(Get-Date -Format "u") + +```text +$trimmedOutput +``` +"@ + + & gh pr comment $prNumber --body $commentBody 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host "Posted validation results to PR #$prNumber" -ForegroundColor Green + } else { + Write-Host "WARNING: Failed to post validation comment to PR #$prNumber" -ForegroundColor Yellow + } + } +} + +exit $validationExitCode From 7c924ec4888d89b13ba8fb0862488ebf003750d9 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 16:19:08 +0100 Subject: [PATCH 29/38] docs: update README for PR-comment validation and reduced CI checks --- README.md | 1 + vm-samples/README.md | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/README.md b/README.md index 16ef24f..370d8e6 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Confidential computing is the protection of data-in-use through isolating comput | **[Sealed Container (ACI) — updated](aci-samples/sealed-container/README.md)** 🆕 | New sealed-container guidance and features: `-WelcomeSecret` encryption/decryption flow, artifact integrity verification walkthrough (checksums vs signatures), clearer UI section highlighting, and updated deployment defaults/documentation. | | **[Federated Multi-Party Demo](/multi-party-samples/advanced-app-federated/README.md)** ⭐ | New 4-party (Contoso, Fabrikam, Wingtip Toys, Woodgrove Bank) **federated** analytics demo. Each partner decrypts its own data inside its own AMD SEV-SNP TEE and returns only aggregates — no raw PII ever crosses the trust boundary. Includes a 3-minute [`DEMO-SCRIPT.md`](/multi-party-samples/advanced-app-federated/DEMO-SCRIPT.md). | | **[CVM samples now support Intel TDX](/vm-samples/README.md)** | [`BuildRandomCVM.ps1`](/vm-samples/BuildRandomCVM.ps1) auto-detects AMD SEV-SNP (`DCa*`/`ECa*`) vs Intel TDX (`DCe*`/`ECe*`, e.g. `Standard_DC2es_v6`) from the chosen VM SKU and runs the matching attestation config. The script now also enables outbound internet on the private VM subnet via NAT Gateway by default, with `-NoInternetAccess` available for fully isolated deployments. The June 2026 validation matrix now includes all four combinations: AMD v6 Windows, AMD v6 Linux, TDX v6 Windows, and TDX v6 Linux. See the [Intel TDX examples](/vm-samples/README.md#intel-tdx-examples) in the VM samples README. | +| **Validation workflow update** 🆕 | Local `pre-push` now posts CVM validation output as a **comment on the active PR** (when `gh` CLI is authenticated). Commit-message annotation is removed. GitHub Actions is temporarily running **secret scan + syntax/parameter validation only** until service principal secrets are available. See [Validation Automation](docs/VALIDATION-AUTOMATION.md). | | **Updated in-VM attestation tooling** | The CVM build script now runs the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM (Linux + Windows), then **decodes the returned MAA JWT** (header, payload and key claims like `x-ms-attestation-type` and `x-ms-compliance-status`) using `jq` on Linux and built-in `ConvertFrom-Json` on Windows. Linux extraction now supports `unzip`, `python3`, or `bsdtar` fallback paths to avoid image-specific package gaps. When `-NoInternetAccess` is used, this attestation step is skipped because the VM cannot reach GitHub to download the tooling. The legacy [`WindowsAttest.ps1`](/vm-samples/WindowsAttest.ps1) is kept for reference but is **no longer recommended**. | | **Repo redirect** | [`https://aka.ms/accsamples`](https://aka.ms/accsamples) now points to this repo. The legacy [`confidential-container-samples`](https://github.com/Azure-Samples/confidential-container-samples) repo remains read-only / archived for reference. | diff --git a/vm-samples/README.md b/vm-samples/README.md index 259f7a9..3ddda0b 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -82,6 +82,14 @@ Before running `BuildRandomCVM.ps1`, ensure you have the following: - **AZURE_SUBSCRIPTION_ID** environment variable set and authenticated via Azure CLI - Run `Set-AzContext -SubscriptionId ""` if needed +### Validation workflow (local + CI) + +- Install hooks once per clone with `./scripts/Install-PreCommitHook.ps1` from repo root. +- `pre-commit` performs secret scanning. +- `pre-push` runs `scripts/validate-cvm.ps1` and blocks push on failures. +- On successful local validation, `pre-push` posts validation output as a comment to the active PR when `gh` CLI is installed/authenticated and a PR exists for the branch. +- GitHub Actions currently runs secret scan + syntax/parameter checks only; cloud CVM matrix validation is temporarily disabled until service principal secrets are configured. + ### Pre-flight checks (before any resources are created) To save you a half-built deployment in a region or subscription that can't actually host the VM, the script runs the following checks **before** creating the resource group, Key Vault, DES, VNet or VM: From 0c8da9ce25d404863bf4afadfe658a5fbd95f118 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 16:22:21 +0100 Subject: [PATCH 30/38] chore: cosmetic comment update in BuildRandomCVM --- vm-samples/BuildRandomCVM.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index 40c6834..0695b6f 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -24,8 +24,8 @@ # You'll need to have the latest Azure PowerShell module installed as older versions don't have the parameters for AKV & ACC (update-module -force) # -# TODO -# - look at the credential handling, it's not optimal +# TODO (cosmetic/docs) +# - review credential handling guidance; current flow is functional but not ideal # handle command line parameters, mandatory, will force you to enter them param ( From ea71e2e1a9e9e7c5d23024cc6b8b02f5996f3686 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 16:44:48 +0100 Subject: [PATCH 31/38] fix: include basic attestation fields in PR matrix comment --- scripts/post-validation-comment.ps1 | 43 ++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 index 3786065..1f5deb9 100644 --- a/scripts/post-validation-comment.ps1 +++ b/scripts/post-validation-comment.ps1 @@ -31,6 +31,42 @@ Write-Host "CVM 4-Way Validation Matrix with GitHub PR Comment" -ForegroundColor Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan Write-Host "" +function Get-AttestationSummary { + param( + [Parameter(Mandatory)] + [string]$Text + ) + + $attestationType = $null + $compliance = $null + $secureBoot = $null + $tpmEnabled = $null + + $typeMatch = [regex]::Match($Text, 'x-ms-attestation-type\s*[":=]\s*"?([A-Za-z0-9\-]+)"?', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($typeMatch.Success) { $attestationType = $typeMatch.Groups[1].Value } + + $complianceMatch = [regex]::Match($Text, 'x-ms-compliance-status\s*[":=]\s*"?([A-Za-z0-9\-]+)"?', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($complianceMatch.Success) { $compliance = $complianceMatch.Groups[1].Value } + + $secureBootMatch = [regex]::Match($Text, 'secure-boot\s*[":=]\s*(true|false|True|False)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($secureBootMatch.Success) { $secureBoot = $secureBootMatch.Groups[1].Value.ToLower() } + + $tpmMatch = [regex]::Match($Text, 'tpm-enabled\s*[":=]\s*(true|false|True|False)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($tpmMatch.Success) { $tpmEnabled = $tpmMatch.Groups[1].Value.ToLower() } + + $parts = @() + if ($attestationType) { $parts += "type=$attestationType" } + if ($compliance) { $parts += "compliance=$compliance" } + if ($secureBoot) { $parts += "secureBoot=$secureBoot" } + if ($tpmEnabled) { $parts += "tpm=$tpmEnabled" } + + if ($parts.Count -eq 0) { + return "attestation-details-unavailable" + } + + return ($parts -join ", ") +} + # Capture validation output $outputLines = @() $outputLines += "# CVM 4-Way Validation Results" @@ -55,10 +91,9 @@ foreach($scenario in $scenarios) { try { $output = & ./BuildRandomCVM.ps1 -subsID $subsID -basename $scenario.basename -osType $scenario.os -region $scenario.region -vmsize $scenario.vmsize -smoketest -DisableBastion -ErrorAction Stop 2>&1 - - # Extract attestation info - $attestationMatch = $output | Select-String -Pattern "(x-ms-attestation-type|x-ms-compliance-status|ATTEST_TYPE|COMPLIANCE|SECURE_BOOT|TPM_ENABLED)" | Select-Object -First 3 - $attestationStr = if ($attestationMatch) { ($attestationMatch | ForEach-Object {$_.Line.Trim()}) -join " / " } else { "N/A" } + + $outputText = ($output | Out-String) + $attestationStr = Get-AttestationSummary -Text $outputText Write-Host " ✓ $($scenario.name) PASSED" -ForegroundColor Green $outputLines += "| $($scenario.name) | ✅ PASS | $attestationStr |" From 486d1c806de72bc31f60eb343f7ea7b6dab1fbf7 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 16:47:06 +0100 Subject: [PATCH 32/38] fix: capture all streams for attestation parsing in PR comment --- scripts/post-validation-comment.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 index 1f5deb9..2c01185 100644 --- a/scripts/post-validation-comment.ps1 +++ b/scripts/post-validation-comment.ps1 @@ -90,7 +90,7 @@ foreach($scenario in $scenarios) { Write-Host "▶ Testing: $($scenario.name)" -ForegroundColor White try { - $output = & ./BuildRandomCVM.ps1 -subsID $subsID -basename $scenario.basename -osType $scenario.os -region $scenario.region -vmsize $scenario.vmsize -smoketest -DisableBastion -ErrorAction Stop 2>&1 + $output = & ./BuildRandomCVM.ps1 -subsID $subsID -basename $scenario.basename -osType $scenario.os -region $scenario.region -vmsize $scenario.vmsize -smoketest -DisableBastion -ErrorAction Stop *>&1 $outputText = ($output | Out-String) $attestationStr = Get-AttestationSummary -Text $outputText From d37d482743637ca7a36d8690af4363fa7ec0aa10 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 17:01:46 +0100 Subject: [PATCH 33/38] fix: run matrix in parallel and improve attestation/comment accuracy --- scripts/post-validation-comment.ps1 | 138 ++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 29 deletions(-) diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 index 2c01185..74e1b0f 100644 --- a/scripts/post-validation-comment.ps1 +++ b/scripts/post-validation-comment.ps1 @@ -18,7 +18,8 @@ Path to save validation output before posting. param( [string]$subsID = "68432aaa-6eba-435c-bc7c-1d998d835e80", - [string]$OutputFile = "./cvm-validation-results.txt" + [string]$OutputFile = "./cvm-validation-results.txt", + [switch]$Sequential ) # Ensure we're in the vm-samples directory @@ -37,22 +38,46 @@ function Get-AttestationSummary { [string]$Text ) - $attestationType = $null - $compliance = $null - $secureBoot = $null - $tpmEnabled = $null + function Get-FirstMatch { + param( + [string]$Source, + [string[]]$Patterns + ) - $typeMatch = [regex]::Match($Text, 'x-ms-attestation-type\s*[":=]\s*"?([A-Za-z0-9\-]+)"?', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) - if ($typeMatch.Success) { $attestationType = $typeMatch.Groups[1].Value } + foreach ($pattern in $Patterns) { + $m = [regex]::Match($Source, $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($m.Success) { + return $m.Groups[1].Value + } + } + return $null + } + + $attestationType = Get-FirstMatch -Source $Text -Patterns @( + 'x-ms-attestation-type\s*[":=]\s*"?([A-Za-z0-9\-]+)"?', + '"x-ms-attestation-type"\s*:\s*"([A-Za-z0-9\-]+)"' + ) - $complianceMatch = [regex]::Match($Text, 'x-ms-compliance-status\s*[":=]\s*"?([A-Za-z0-9\-]+)"?', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) - if ($complianceMatch.Success) { $compliance = $complianceMatch.Groups[1].Value } + $compliance = Get-FirstMatch -Source $Text -Patterns @( + 'x-ms-compliance-status\s*[":=]\s*"?([A-Za-z0-9\-]+)"?', + '"x-ms-compliance-status"\s*:\s*"([A-Za-z0-9\-]+)"' + ) - $secureBootMatch = [regex]::Match($Text, 'secure-boot\s*[":=]\s*(true|false|True|False)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) - if ($secureBootMatch.Success) { $secureBoot = $secureBootMatch.Groups[1].Value.ToLower() } + $secureBoot = Get-FirstMatch -Source $Text -Patterns @( + 'secure-boot\s*[":=]\s*(true|false|True|False)', + '"secure-boot"\s*:\s*(true|false)', + 'x-ms-runtime-vm-configuration-secure-boot\s*[":=]\s*(true|false|True|False)', + '"x-ms-runtime-vm-configuration-secure-boot"\s*:\s*(true|false)' + ) + if ($secureBoot) { $secureBoot = $secureBoot.ToLower() } - $tpmMatch = [regex]::Match($Text, 'tpm-enabled\s*[":=]\s*(true|false|True|False)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) - if ($tpmMatch.Success) { $tpmEnabled = $tpmMatch.Groups[1].Value.ToLower() } + $tpmEnabled = Get-FirstMatch -Source $Text -Patterns @( + 'tpm-enabled\s*[":=]\s*(true|false|True|False)', + '"tpm-enabled"\s*:\s*(true|false)', + 'x-ms-runtime-vm-configuration-tpm-enabled\s*[":=]\s*(true|false|True|False)', + '"x-ms-runtime-vm-configuration-tpm-enabled"\s*:\s*(true|false)' + ) + if ($tpmEnabled) { $tpmEnabled = $tpmEnabled.ToLower() } $parts = @() if ($attestationType) { $parts += "type=$attestationType" } @@ -73,12 +98,14 @@ $outputLines += "# CVM 4-Way Validation Results" $outputLines += "" $outputLines += "**Date:** $(Get-Date -Format 'u')" $outputLines += "" +$outputLines += "**Execution mode:** $(if ($Sequential) { 'sequential' } else { 'parallel' })" +$outputLines += "" $outputLines += "| Scenario | Status | Attestation |" $outputLines += "|----------|--------|-------------|" $scenarios = @( - @{name="AMD SEV-SNP v6 Windows"; os="Windows"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="amdw"} - @{name="AMD SEV-SNP v6 Linux"; os="Ubuntu"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="amdl"} + @{name="AMD SEV-SNP v6 Windows"; os="Windows"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="amdw"; skipPreflight=$true} + @{name="AMD SEV-SNP v6 Linux"; os="Ubuntu"; region="koreacentral"; vmsize="Standard_DC2as_v6"; basename="amdl"; skipPreflight=$true} @{name="Intel TDX v6 Windows"; os="Windows"; region="westeurope"; vmsize="Standard_DC2es_v6"; basename="tdxw"} @{name="Intel TDX v6 Linux"; os="Ubuntu"; region="westeurope"; vmsize="Standard_DC2es_v6"; basename="tdxl"} ) @@ -86,26 +113,79 @@ $scenarios = @( $passed = 0 $failed = 0 -foreach($scenario in $scenarios) { - Write-Host "▶ Testing: $($scenario.name)" -ForegroundColor White - - try { - $output = & ./BuildRandomCVM.ps1 -subsID $subsID -basename $scenario.basename -osType $scenario.os -region $scenario.region -vmsize $scenario.vmsize -smoketest -DisableBastion -ErrorAction Stop *>&1 +$jobScript = { + param($scenario, $subscriptionId, $samplesPath) + + Set-Location $samplesPath + $args = @( + '-subsID', $subscriptionId, + '-basename', $scenario.basename, + '-osType', $scenario.os, + '-region', $scenario.region, + '-vmsize', $scenario.vmsize, + '-smoketest', + '-DisableBastion' + ) - $outputText = ($output | Out-String) - $attestationStr = Get-AttestationSummary -Text $outputText - + if ($scenario.skipPreflight) { + $args += '-SkipSkuPreflight' + } + + $output = & ./BuildRandomCVM.ps1 @args *>&1 + $outputText = ($output | Out-String) + $exitCode = $LASTEXITCODE + + [PSCustomObject]@{ + name = $scenario.name + outputText = $outputText + exitCode = $exitCode + } +} + +$results = @() +if ($Sequential) { + foreach ($scenario in $scenarios) { + Write-Host "▶ Testing: $($scenario.name)" -ForegroundColor White + $results += & $jobScript $scenario $subsID $vmSamplesPath + Write-Host "" + } +} else { + Write-Host "Starting all 4 scenarios in parallel..." -ForegroundColor Cyan + $jobs = @() + foreach ($scenario in $scenarios) { + $jobs += Start-Job -ScriptBlock $jobScript -ArgumentList $scenario, $subsID, $vmSamplesPath + } + + Wait-Job -Job $jobs | Out-Null + $results = $jobs | Receive-Job + $jobs | Remove-Job -Force | Out-Null +} + +foreach ($scenario in $scenarios) { + $result = $results | Where-Object { $_.name -eq $scenario.name } | Select-Object -First 1 + if (-not $result) { + Write-Host " ✗ $($scenario.name) FAILED: no result" -ForegroundColor Red + $outputLines += "| $($scenario.name) | ❌ FAIL | result-unavailable |" + $failed++ + continue + } + + $attestationStr = Get-AttestationSummary -Text $result.outputText + + $sawCompletion = $result.outputText -match 'Build and attestation complete' + $sawFatalError = $result.outputText -match 'ERROR:\s|FAILED:\s|VM deployment failed' + $isPass = ($result.exitCode -eq 0 -or $null -eq $result.exitCode) -and $sawCompletion -and (-not $sawFatalError) + + if ($isPass) { Write-Host " ✓ $($scenario.name) PASSED" -ForegroundColor Green $outputLines += "| $($scenario.name) | ✅ PASS | $attestationStr |" $passed++ - } - catch { - Write-Host " ✗ $($scenario.name) FAILED: $_" -ForegroundColor Red - $outputLines += "| $($scenario.name) | ❌ FAIL | Error: $_ |" + } else { + Write-Host " ✗ $($scenario.name) FAILED" -ForegroundColor Red + $errorHint = if ($result.outputText -match 'NotAvailableForSubscription') { 'sku-not-available-for-subscription' } elseif ($result.outputText -match 'Run-command extension busy') { 'run-command-timeout-or-busy' } else { 'deployment-or-attestation-failure' } + $outputLines += "| $($scenario.name) | ❌ FAIL | $attestationStr ($errorHint) |" $failed++ } - - Write-Host "" } $outputLines += "" From e7848765aa2b609b9952974067e01ae6b72e17a9 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 17:02:21 +0100 Subject: [PATCH 34/38] fix: correct parallel job argument binding for matrix runner --- scripts/post-validation-comment.ps1 | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 index 74e1b0f..95277c4 100644 --- a/scripts/post-validation-comment.ps1 +++ b/scripts/post-validation-comment.ps1 @@ -34,10 +34,13 @@ Write-Host "" function Get-AttestationSummary { param( - [Parameter(Mandatory)] [string]$Text ) + if ([string]::IsNullOrWhiteSpace($Text)) { + return "attestation-details-unavailable" + } + function Get-FirstMatch { param( [string]$Source, @@ -117,21 +120,12 @@ $jobScript = { param($scenario, $subscriptionId, $samplesPath) Set-Location $samplesPath - $args = @( - '-subsID', $subscriptionId, - '-basename', $scenario.basename, - '-osType', $scenario.os, - '-region', $scenario.region, - '-vmsize', $scenario.vmsize, - '-smoketest', - '-DisableBastion' - ) if ($scenario.skipPreflight) { - $args += '-SkipSkuPreflight' + $output = & ./BuildRandomCVM.ps1 -subsID $subscriptionId -basename $scenario.basename -osType $scenario.os -region $scenario.region -vmsize $scenario.vmsize -smoketest -DisableBastion -SkipSkuPreflight *>&1 + } else { + $output = & ./BuildRandomCVM.ps1 -subsID $subscriptionId -basename $scenario.basename -osType $scenario.os -region $scenario.region -vmsize $scenario.vmsize -smoketest -DisableBastion *>&1 } - - $output = & ./BuildRandomCVM.ps1 @args *>&1 $outputText = ($output | Out-String) $exitCode = $LASTEXITCODE From d6dc371a46f313f0a59725f114ffa3f2ad06c663 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 17:04:53 +0100 Subject: [PATCH 35/38] chore: disable automatic PR validation comments --- scripts/post-validation-comment.ps1 | 74 +++++++++++++++-------------- scripts/pre-push.ps1 | 36 -------------- 2 files changed, 39 insertions(+), 71 deletions(-) diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 index 95277c4..6c10ada 100644 --- a/scripts/post-validation-comment.ps1 +++ b/scripts/post-validation-comment.ps1 @@ -1,10 +1,10 @@ <# .SYNOPSIS -Captures CVM 4-way validation output and posts it as a GitHub PR comment. +Captures CVM 4-way validation output and optionally posts it as a GitHub PR comment. .DESCRIPTION -Runs the 4-way CVM validation matrix, captures results, and posts them as a comment -on the active pull request using the GitHub CLI. +Runs the 4-way CVM validation matrix and captures results to a local file. +Use -PostToPr to explicitly post the result as a pull request comment. .PARAMETER subsID Azure Subscription ID for deployments. @@ -19,7 +19,8 @@ Path to save validation output before posting. param( [string]$subsID = "68432aaa-6eba-435c-bc7c-1d998d835e80", [string]$OutputFile = "./cvm-validation-results.txt", - [switch]$Sequential + [switch]$Sequential, + [switch]$PostToPr ) # Ensure we're in the vm-samples directory @@ -28,7 +29,7 @@ $vmSamplesPath = Join-Path $vmSamplesPath "vm-samples" Set-Location $vmSamplesPath Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan -Write-Host "CVM 4-Way Validation Matrix with GitHub PR Comment" -ForegroundColor Cyan +Write-Host "CVM 4-Way Validation Matrix" -ForegroundColor Cyan Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan Write-Host "" @@ -194,41 +195,44 @@ $outputLines += "" $outputLines | Out-File -FilePath $OutputFile -Encoding UTF8 Write-Host "Validation output saved to: $OutputFile" -ForegroundColor Yellow -# Try to post as GitHub PR comment -try { - # Check if GitHub CLI is available - $ghVersion = & gh --version 2>$null - - if ($LASTEXITCODE -eq 0) { - Write-Host "" - Write-Host "Posting comment to active pull request..." -ForegroundColor Cyan - - # Get the current PR number (if on a PR branch) - $prNumber = & gh pr view --json number -q .number 2>$null - - if ($LASTEXITCODE -eq 0 -and $prNumber) { - $commentBody = $outputLines -join "`n" - & gh pr comment $prNumber --body $commentBody - - if ($LASTEXITCODE -eq 0) { - Write-Host "✓ Comment posted to PR #$prNumber" -ForegroundColor Green +if ($PostToPr) { + try { + # Check if GitHub CLI is available + & gh --version 2>$null | Out-Null + + if ($LASTEXITCODE -eq 0) { + Write-Host "" + Write-Host "Posting comment to active pull request..." -ForegroundColor Cyan + + # Get the current PR number (if on a PR branch) + $prNumber = & gh pr view --json number -q .number 2>$null + + if ($LASTEXITCODE -eq 0 -and $prNumber) { + $commentBody = $outputLines -join "`n" + & gh pr comment $prNumber --body $commentBody + + if ($LASTEXITCODE -eq 0) { + Write-Host "✓ Comment posted to PR #$prNumber" -ForegroundColor Green + } else { + Write-Host "⚠ Failed to post comment (error: $LASTEXITCODE)" -ForegroundColor Yellow + Write-Host "Run manually: gh pr comment --body-file $OutputFile" -ForegroundColor Yellow + } } else { - Write-Host "⚠ Failed to post comment (error: $LASTEXITCODE)" -ForegroundColor Yellow - Write-Host "Run manually: gh pr comment --body-file $OutputFile" -ForegroundColor Yellow + Write-Host "⚠ No active PR found on this branch" -ForegroundColor Yellow + Write-Host "To post manually, run:" -ForegroundColor Yellow + Write-Host " gh pr comment --body-file '$OutputFile'" -ForegroundColor Cyan } } else { - Write-Host "⚠ No active PR found on this branch" -ForegroundColor Yellow - Write-Host "To post manually, run:" -ForegroundColor Yellow - Write-Host " gh pr comment --body-file '$OutputFile'" -ForegroundColor Cyan + Write-Host "⚠ GitHub CLI (gh) not found. Install from: https://cli.github.com/" -ForegroundColor Yellow + Write-Host "Validation output saved to: $OutputFile" -ForegroundColor Yellow } - } else { - Write-Host "⚠ GitHub CLI (gh) not found. Install from: https://cli.github.com/" -ForegroundColor Yellow - Write-Host "Validation output saved to: $OutputFile" -ForegroundColor Yellow } -} -catch { - Write-Host "⚠ Error posting comment: $_" -ForegroundColor Yellow - Write-Host "Output saved to: $OutputFile" -ForegroundColor Yellow + catch { + Write-Host "⚠ Error posting comment: $_" -ForegroundColor Yellow + Write-Host "Output saved to: $OutputFile" -ForegroundColor Yellow + } +} else { + Write-Host "PR comment posting is disabled. Use -PostToPr to publish this result." -ForegroundColor DarkYellow } Write-Host "" diff --git a/scripts/pre-push.ps1 b/scripts/pre-push.ps1 index b215bcc..840e096 100644 --- a/scripts/pre-push.ps1 +++ b/scripts/pre-push.ps1 @@ -30,40 +30,4 @@ $validationExitCode = $LASTEXITCODE Write-Host $validationOutput -# Best-effort PR comment: do not block push if GitHub comment fails. -if (Get-Command gh -ErrorAction SilentlyContinue) { - $prNumber = & gh pr view --json number -q .number 2>$null - if ($LASTEXITCODE -eq 0 -and $prNumber) { - $branch = git rev-parse --abbrev-ref HEAD - $statusLabel = if ($validationExitCode -eq 0) { "PASS" } else { "FAIL" } - $statusIcon = if ($validationExitCode -eq 0) { "✅" } else { "❌" } - - $lines = $validationOutput -split "`r?`n" - $maxLines = 200 - if ($lines.Count -gt $maxLines) { - $lines = $lines[($lines.Count - $maxLines)..($lines.Count - 1)] - $lines = @("[truncated to last $maxLines lines]") + $lines - } - $trimmedOutput = ($lines -join "`n").Trim() - - $commentBody = @" -## $statusIcon Local Pre-Push CVM Validation: $statusLabel - -- Branch: $branch -- Timestamp (UTC): $(Get-Date -Format "u") - -```text -$trimmedOutput -``` -"@ - - & gh pr comment $prNumber --body $commentBody 2>$null | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Host "Posted validation results to PR #$prNumber" -ForegroundColor Green - } else { - Write-Host "WARNING: Failed to post validation comment to PR #$prNumber" -ForegroundColor Yellow - } - } -} - exit $validationExitCode From d61f9f64fbfa6d089b020ed803979ac3d11336d5 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 17:05:33 +0100 Subject: [PATCH 36/38] docs: update hook installer output for no PR auto-comments --- scripts/Install-PreCommitHook.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Install-PreCommitHook.ps1 b/scripts/Install-PreCommitHook.ps1 index 0cd76d7..a2f0195 100644 --- a/scripts/Install-PreCommitHook.ps1 +++ b/scripts/Install-PreCommitHook.ps1 @@ -102,7 +102,7 @@ Write-Host " pre-push runs: .\scripts\validate-cvm.ps1" -ForegroundColor White Write-Host " • PowerShell syntax checks" Write-Host " • ARM template validation (dry-run)" Write-Host " • Parameter file validation" -Write-Host " • Posts validation results as GitHub PR comment (if gh CLI + active PR)" +Write-Host " • Does not post GitHub PR comments automatically" Write-Host "" Write-Host " Emergency bypass:" -ForegroundColor Yellow Write-Host " git commit --no-verify (skip pre-commit hook)" -ForegroundColor Gray From 4b5f1573bf1942d2026291974fb1e471d6497520 Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 17:11:32 +0100 Subject: [PATCH 37/38] feat: re-enable matrix PR auto-comments by default --- scripts/post-validation-comment.ps1 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 index 6c10ada..08f9501 100644 --- a/scripts/post-validation-comment.ps1 +++ b/scripts/post-validation-comment.ps1 @@ -4,7 +4,8 @@ Captures CVM 4-way validation output and optionally posts it as a GitHub PR comm .DESCRIPTION Runs the 4-way CVM validation matrix and captures results to a local file. -Use -PostToPr to explicitly post the result as a pull request comment. +Posts the result as a pull request comment by default. +Use -NoPostToPr to skip pull request comment posting. .PARAMETER subsID Azure Subscription ID for deployments. @@ -20,7 +21,7 @@ param( [string]$subsID = "68432aaa-6eba-435c-bc7c-1d998d835e80", [string]$OutputFile = "./cvm-validation-results.txt", [switch]$Sequential, - [switch]$PostToPr + [switch]$NoPostToPr ) # Ensure we're in the vm-samples directory @@ -195,7 +196,7 @@ $outputLines += "" $outputLines | Out-File -FilePath $OutputFile -Encoding UTF8 Write-Host "Validation output saved to: $OutputFile" -ForegroundColor Yellow -if ($PostToPr) { +if (-not $NoPostToPr) { try { # Check if GitHub CLI is available & gh --version 2>$null | Out-Null @@ -232,7 +233,7 @@ if ($PostToPr) { Write-Host "Output saved to: $OutputFile" -ForegroundColor Yellow } } else { - Write-Host "PR comment posting is disabled. Use -PostToPr to publish this result." -ForegroundColor DarkYellow + Write-Host "PR comment posting skipped because -NoPostToPr was provided." -ForegroundColor DarkYellow } Write-Host "" From 3df83441e4087f1f850661308350b47b6203344a Mon Sep 17 00:00:00 2001 From: Simon Gallagher Date: Fri, 26 Jun 2026 17:22:07 +0100 Subject: [PATCH 38/38] docs: update validation workflow docs for matrix PR comments --- README.md | 2 +- vm-samples/README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 370d8e6..8013577 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Confidential computing is the protection of data-in-use through isolating comput | **[Sealed Container (ACI) — updated](aci-samples/sealed-container/README.md)** 🆕 | New sealed-container guidance and features: `-WelcomeSecret` encryption/decryption flow, artifact integrity verification walkthrough (checksums vs signatures), clearer UI section highlighting, and updated deployment defaults/documentation. | | **[Federated Multi-Party Demo](/multi-party-samples/advanced-app-federated/README.md)** ⭐ | New 4-party (Contoso, Fabrikam, Wingtip Toys, Woodgrove Bank) **federated** analytics demo. Each partner decrypts its own data inside its own AMD SEV-SNP TEE and returns only aggregates — no raw PII ever crosses the trust boundary. Includes a 3-minute [`DEMO-SCRIPT.md`](/multi-party-samples/advanced-app-federated/DEMO-SCRIPT.md). | | **[CVM samples now support Intel TDX](/vm-samples/README.md)** | [`BuildRandomCVM.ps1`](/vm-samples/BuildRandomCVM.ps1) auto-detects AMD SEV-SNP (`DCa*`/`ECa*`) vs Intel TDX (`DCe*`/`ECe*`, e.g. `Standard_DC2es_v6`) from the chosen VM SKU and runs the matching attestation config. The script now also enables outbound internet on the private VM subnet via NAT Gateway by default, with `-NoInternetAccess` available for fully isolated deployments. The June 2026 validation matrix now includes all four combinations: AMD v6 Windows, AMD v6 Linux, TDX v6 Windows, and TDX v6 Linux. See the [Intel TDX examples](/vm-samples/README.md#intel-tdx-examples) in the VM samples README. | -| **Validation workflow update** 🆕 | Local `pre-push` now posts CVM validation output as a **comment on the active PR** (when `gh` CLI is authenticated). Commit-message annotation is removed. GitHub Actions is temporarily running **secret scan + syntax/parameter validation only** until service principal secrets are available. See [Validation Automation](docs/VALIDATION-AUTOMATION.md). | +| **Validation workflow update** 🆕 | Local `pre-push` performs validation only (no PR commenting). Use `./scripts/post-validation-comment.ps1` to run the 4-way CVM matrix and auto-post results as a **comment on the active PR** (use `-NoPostToPr` to skip posting). Commit-message annotation is removed. GitHub Actions is temporarily running **secret scan + syntax/parameter validation only** until service principal secrets are available. See [Validation Automation](docs/VALIDATION-AUTOMATION.md). | | **Updated in-VM attestation tooling** | The CVM build script now runs the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM (Linux + Windows), then **decodes the returned MAA JWT** (header, payload and key claims like `x-ms-attestation-type` and `x-ms-compliance-status`) using `jq` on Linux and built-in `ConvertFrom-Json` on Windows. Linux extraction now supports `unzip`, `python3`, or `bsdtar` fallback paths to avoid image-specific package gaps. When `-NoInternetAccess` is used, this attestation step is skipped because the VM cannot reach GitHub to download the tooling. The legacy [`WindowsAttest.ps1`](/vm-samples/WindowsAttest.ps1) is kept for reference but is **no longer recommended**. | | **Repo redirect** | [`https://aka.ms/accsamples`](https://aka.ms/accsamples) now points to this repo. The legacy [`confidential-container-samples`](https://github.com/Azure-Samples/confidential-container-samples) repo remains read-only / archived for reference. | diff --git a/vm-samples/README.md b/vm-samples/README.md index 3ddda0b..45fb16a 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -87,7 +87,8 @@ Before running `BuildRandomCVM.ps1`, ensure you have the following: - Install hooks once per clone with `./scripts/Install-PreCommitHook.ps1` from repo root. - `pre-commit` performs secret scanning. - `pre-push` runs `scripts/validate-cvm.ps1` and blocks push on failures. -- On successful local validation, `pre-push` posts validation output as a comment to the active PR when `gh` CLI is installed/authenticated and a PR exists for the branch. +- Run `./scripts/post-validation-comment.ps1` from repo root to execute the 4-way CVM matrix (parallel by default) and post results as a comment on the active PR. +- Use `-NoPostToPr` with `post-validation-comment.ps1` when you want a local-only run without posting a PR comment. - GitHub Actions currently runs secret scan + syntax/parameter checks only; cloud CVM matrix validation is temporarily disabled until service principal secrets are configured. ### Pre-flight checks (before any resources are created)