diff --git a/.github/workflows/cvm-validation.yml b/.github/workflows/cvm-validation.yml new file mode 100644 index 0000000..c3031c9 --- /dev/null +++ b/.github/workflows/cvm-validation.yml @@ -0,0 +1,81 @@ +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 + + summary: + name: Validation Summary + runs-on: windows-latest + needs: [secret-scan, validate-syntax] + 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 }}" + } + + 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") { + 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/README.md b/README.md index e2d8a4c..8013577 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,11 @@ 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**. | +| **[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` 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. | ### Previously (May 2026) @@ -156,7 +158,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/aci-samples/README.md b/aci-samples/README.md index 1a0991d..a19eec5 100644 --- a/aci-samples/README.md +++ b/aci-samples/README.md @@ -48,6 +48,18 @@ 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. | + +## 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) --- 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..676f92e --- /dev/null +++ b/aci-samples/sealed-container/Build-SealedArtifacts.ps1 @@ -0,0 +1,779 @@ +<# +.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. + + 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. + +.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: sealaci). 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 = 'sealaci', + [string]$Location = 'eastus', + [string]$SubscriptionId, + [string]$TrustedSourceCidr, + [string]$WelcomeSecret +) + +$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 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() +} + +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 } + #> + 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( + $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( + (@{ 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 + } +} + +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) +# ---------------------------------------------------------------------------- +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-OptionalTools | Out-Null + 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" + $secret = Get-WelcomeSecret + $sealed = New-SealedBundle -SecretPlaintext $secret + $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..33526f6 --- /dev/null +++ b/aci-samples/sealed-container/Deploy-SealedContainer.ps1 @@ -0,0 +1,218 @@ +<# +.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)" + az deployment group create ` + --resource-group $cfg.resourceGroup ` + --name $cgName ` + --template-file (Join-Path $ScriptDir 'deployment-template.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 + } + + $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 + + 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..f8e4809 --- /dev/null +++ b/aci-samples/sealed-container/README.md @@ -0,0 +1,347 @@ +# 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 +``` + +## 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 +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. + +## 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 +# 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 sealaci 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 + +# 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 + +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..c648c0f --- /dev/null +++ b/aci-samples/sealed-container/app.py @@ -0,0 +1,482 @@ +""" +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 cryptography.hazmat.primitives.ciphers.aead import AESGCM +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)}] + + welcome_secret = decode_welcome_secret(info) + 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"), + "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 +# 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..4ad156c --- /dev/null +++ b/aci-samples/sealed-container/deployment-template.json @@ -0,0 +1,119 @@ +{ + "$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, for example /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": "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')]" } + ], + "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..5500aa6 --- /dev/null +++ b/aci-samples/sealed-container/deployment-template.json.sig @@ -0,0 +1,8 @@ +{ + "file": "deployment-template.json", + "digest": "17a20366afd74a14e2ad033633e861ca670a9d346404b8d2f7c4c68d39659290", + "signed_at": "2026-06-22T16:58:22Z", + "_placeholder": true, + "note": "cosign is not installed — install it and re-run -Refresh for a real signature.", + "algorithm": "sha256" +} diff --git a/aci-samples/sealed-container/entrypoint.py b/aci-samples/sealed-container/entrypoint.py new file mode 100644 index 0000000..d81ef80 --- /dev/null +++ b/aci-samples/sealed-container/entrypoint.py @@ -0,0 +1,395 @@ +#!/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 +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"] + +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, release_policy_sha256).""" + 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] + 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", + 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, release_policy_sha256 + + +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"], + "bundle_dek_hex": unsealed["bundle_dek_hex"], + "release_policy_sha256": release_policy_sha256, + } + (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_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, release_policy_sha256) + 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..b3a4c39 --- /dev/null +++ b/aci-samples/sealed-container/templates/index.html @@ -0,0 +1,277 @@ +{# 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.

+

+ + +

+
+
+
+ +
+
+ + + + diff --git a/docs/VALIDATION-AUTOMATION.md b/docs/VALIDATION-AUTOMATION.md new file mode 100644 index 0000000..5ab1f52 --- /dev/null +++ b/docs/VALIDATION-AUTOMATION.md @@ -0,0 +1,67 @@ +# Validation Automation + +## Current Behavior + +Validation results are posted as comments on the active GitHub pull request. +Commit-message annotation is no longer used. + +## Local Hooks + +Run once per clone: + +```powershell +.\scripts\Install-PreCommitHook.ps1 +``` + +This installs: +- `pre-commit`: secret scan (`scripts/pre-commit.ps1`) +- `pre-push`: validation (`scripts/validate-cvm.ps1`) and PR comment posting + +## Pre-Push PR Comment Posting + +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 + +If PR comment posting fails, push behavior is unchanged (non-blocking). + +## Manual 4-Way Validation Comment + +For full AMD/TDX x Windows/Linux smoke validation and PR comment: + +```powershell +.\scripts\post-validation-comment.ps1 -subsID "68432aaa-6eba-435c-bc7c-1d998d835e80" +``` + +## GitHub Actions (Temporary Reduced Mode) + +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`. + +## Re-Enable Full CI Matrix Later + +When ready, restore matrix job and add: +- `AZURE_CREDENTIALS` +- `AZURE_SUBSCRIPTION_ID` + +## Troubleshooting + +Check GitHub CLI: + +```bash +gh --version +gh auth status +``` + +Emergency bypass: + +```bash +git commit --no-verify +git push --no-verify +``` diff --git a/docs/VALIDATION-WORKFLOW.md b/docs/VALIDATION-WORKFLOW.md new file mode 100644 index 0000000..008a712 --- /dev/null +++ b/docs/VALIDATION-WORKFLOW.md @@ -0,0 +1,106 @@ +# 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 + +## Local Git Hooks (One-Time Setup) + +To automatically run secret scanning on every commit: + +```powershell +.\scripts\Install-PreCommitHook.ps1 +``` + +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 + +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/Install-PreCommitHook.ps1 b/scripts/Install-PreCommitHook.ps1 index 7f112fc..a2f0195 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,13 @@ 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 " • PowerShell syntax checks" +Write-Host " • ARM template validation (dry-run)" +Write-Host " • Parameter file validation" +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 +Write-Host " git push --no-verify (skip pre-push hook)" -ForegroundColor Gray Write-Host "" diff --git a/scripts/post-validation-comment.ps1 b/scripts/post-validation-comment.ps1 new file mode 100644 index 0000000..08f9501 --- /dev/null +++ b/scripts/post-validation-comment.ps1 @@ -0,0 +1,242 @@ +<# +.SYNOPSIS +Captures CVM 4-way validation output and optionally posts it as a GitHub PR comment. + +.DESCRIPTION +Runs the 4-way CVM validation matrix and captures results to a local file. +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. + +.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", + [switch]$Sequential, + [switch]$NoPostToPr +) + +# 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" -ForegroundColor Cyan +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "" + +function Get-AttestationSummary { + param( + [string]$Text + ) + + if ([string]::IsNullOrWhiteSpace($Text)) { + return "attestation-details-unavailable" + } + + function Get-FirstMatch { + param( + [string]$Source, + [string[]]$Patterns + ) + + 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\-]+)"' + ) + + $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\-]+)"' + ) + + $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() } + + $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" } + 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" +$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"; 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"} +) + +$passed = 0 +$failed = 0 + +$jobScript = { + param($scenario, $subscriptionId, $samplesPath) + + Set-Location $samplesPath + + if ($scenario.skipPreflight) { + $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 + } + $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++ + } 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++ + } +} + +$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 + +if (-not $NoPostToPr) { + 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 "⚠ 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 + } +} else { + Write-Host "PR comment posting skipped because -NoPostToPr was provided." -ForegroundColor DarkYellow +} + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +Write-Host "Validation Complete" -ForegroundColor $(if($failed -eq 0) { "Green" } else { "Red" }) +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan diff --git a/scripts/pre-push.ps1 b/scripts/pre-push.ps1 new file mode 100644 index 0000000..840e096 --- /dev/null +++ b/scripts/pre-push.ps1 @@ -0,0 +1,33 @@ +<# +.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 +} + +$validationOutput = & ./scripts/validate-cvm.ps1 2>&1 | Out-String +$validationExitCode = $LASTEXITCODE + +Write-Host $validationOutput + +exit $validationExitCode diff --git a/scripts/validate-cvm.ps1 b/scripts/validate-cvm.ps1 new file mode 100644 index 0000000..4906beb --- /dev/null +++ b/scripts/validate-cvm.ps1 @@ -0,0 +1,205 @@ +<# +.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/pre-push.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 ' -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,12 +19,13 @@ # 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) # -# 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 ( @@ -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 ) @@ -47,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 @@ -76,6 +165,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 +195,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 +349,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,10 +427,45 @@ 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; -$subnetId = $vnet.Subnets[0].Id; + +# 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 + # 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 | 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; @@ -351,8 +487,38 @@ $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 +} 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) { @@ -370,40 +536,99 @@ 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 = @" #!/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" 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 -unzip -q 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 + 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 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. @@ -427,8 +652,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 = @" @@ -439,7 +664,74 @@ 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 +# 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 + } +} +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 { + Invoke-WebRequest -Uri `$attestUrl -OutFile 'attest-win.zip' -UseBasicParsing + `$dlOk = `$true + } catch { + Write-Host "Invoke-WebRequest attempt `$i failed: `$(`$_.Exception.Message)" -ForegroundColor Yellow + if (`$i -lt 5) { Start-Sleep -Seconds 10 } + } + } +} +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 ---------" @@ -452,8 +744,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 } @@ -497,37 +802,51 @@ 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 + } 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 + } } } -} -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--------------" + $attestationText = "" + foreach ($entry in $output.Value) { + 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 '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" + } + 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..45fb16a 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -50,10 +50,47 @@ 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. +### 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 + +### 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. +- 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) 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: @@ -94,7 +131,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 +144,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) @@ -115,10 +153,87 @@ 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 + +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 "wtdx" -osType "Windows" -region "westeurope" -vmsize "Standard_DC2es_v6" +``` + +**Windows 11 Enterprise with Intel TDX:** +```powershell +./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 "ptdx" -osType "Ubuntu" -region "westeurope" -vmsize "Standard_DC4es_v6" -description "Production TDX workload" +``` + +### AMD SEV-SNP CVM + +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 "wsnp" -osType "Windows" -region "northeurope" -vmsize "Standard_DC2as_v5" +``` + +**Windows 11 Enterprise with AMD SEV-SNP:** +```powershell +./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 "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 +``` + +**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 -./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: @@ -127,7 +242,7 @@ The script will generate a random complex password and output it to the terminal ./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" @@ -138,12 +253,13 @@ 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 "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 ``` -## 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`. @@ -157,17 +273,51 @@ 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" +## 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`) -# Deploy Windows Server 2022 Intel TDX CVM -./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "wintdx" -osType "Windows" -region "westeurope" -vmsize "Standard_DC2es_v6" +### Verify SEV-SNP v6 Availability in Your Region -# Smoketest a TDX deployment -./BuildRandomCVM.ps1 -subsID "your-subscription-id" -basename "tdxci" -osType "Ubuntu" -region "westus3" -vmsize "Standard_DC4es_v6" -smoketest +```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 @@ -175,6 +325,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: @@ -267,6 +418,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. @@ -277,10 +475,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"