From e46da08be280e9e7c5d7392a5117225d3549fd13 Mon Sep 17 00:00:00 2001 From: Hou Chi Chan Date: Fri, 2 Oct 2026 14:42:59 -0700 Subject: [PATCH] Add protected Azure Pipelines E2E validation for GitHub merges --- pipelines/README.md | 166 ++++++++ pipelines/deploy-cyot-e2e.yml | 518 ++++++++++++++++++++++++ pipelines/tests/test_deploy_pipeline.py | 308 ++++++++++++++ 3 files changed, 992 insertions(+) create mode 100644 pipelines/README.md create mode 100644 pipelines/deploy-cyot-e2e.yml create mode 100644 pipelines/tests/test_deploy_pipeline.py diff --git a/pipelines/README.md b/pipelines/README.md new file mode 100644 index 0000000..b6f642a --- /dev/null +++ b/pipelines/README.md @@ -0,0 +1,166 @@ +# CYOT pipeline onboarding + +[deploy-cyot-e2e.yml](deploy-cyot-e2e.yml) builds and tests all three Function runtimes, +with a separate manual, approved deployment and encrypted HTTP evaluation stage. +No environment-specific tenant, subscription or caller application IDs are included in +these pipeline files. Offline tests use explicitly synthetic identifiers. + +## Trigger after PR merge + +In your Azure DevOps project, create a pipeline with **GitHub** as its source, select +`Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample`, and choose the existing YAML +file `/pipelines/deploy-cyot-e2e.yml`. Keep its default branch on `main` and authorize the +Azure Pipelines GitHub integration for this repository. This file is an Azure Pipelines +definition, not a GitHub Actions workflow. It extends 1ES Pipeline Templates and requires +an authorized 1ES template connection and agent pool; it is not standalone hosted-agent YAML. + +The explicit CI trigger runs on updates to this repository's `main`, including completed +PR merges. Direct pushes also trigger CI if GitHub branch protection permits them: require +reviewed PRs and restrict direct pushes on GitHub. With batching enabled, merges arriving +while a run is active can be combined into the next run. `pr: none` disables this pipeline's +GitHub PR-validation trigger; existing GitHub Actions validation remains unchanged. + +These are updates to the EPP GitHub repository, not merges in an internal mirror. CI uses +Azure Pipelines' `Build.SourceVersion` to fetch the exact triggering commit from the fixed +public repository and verifies the fetched SHA. It never resolves a later moving `main`. +The default `sourceCommit: triggeringCommit` verifies the GitHub provider and repository +name; it is rejected when this YAML is instead registered against an Azure Repos mirror. +An automatic CI run cannot override that commit or enable deployment. + +**Before enabling automatic runs**, replace the `REQUIRED` defaults for `templateConnection`, +`poolName`, and `poolImage` with approved nonsecret values and authorize those resources. +CI cannot answer parameter prompts or use an unconfigured pool/template connection. +Do not enable a UI trigger override that disables the YAML trigger. + +Automatic runs are build-only: `deployAndTest` defaults to `false`, and the Azure stage +is omitted along with its protected variable group. A deployment requires a manual run +from reviewed GitHub `main`, `deployAndTest: true`, and a full lowercase 40-character +`sourceCommit`. The `triggeringCommit` shortcut is rejected for deployment. Approval and +an exclusive-lock check must exist on the selected ADO Environment; YAML does not create them. + +## Build inputs + +| Parameter | Value | +| --- | --- | +| `sourceCommit` | `triggeringCommit` for build-only, or an explicit reviewed full SHA for manual runs | +| `templateConnection` | Approved read-only connection to `1ESPipelineTemplates/1ESPipelineTemplates` | +| `poolName` | Approved clean Linux x64 1ES pool | +| `poolImage` | Approved image with PowerShell 7.4+, Git, Python 3.11 tool cache, and an Az installation supported by AzurePowerShell@5 | +| `deployAndTest` | Leave `false` for CI; explicit manual opt-in for Azure work | + +The build uses .NET 8, Node.js 22, and Python 3.11. It executes nonempty unit suites, +rejects failures/skips, and packages all three implementations from the same SHA. +Python dependencies are packaged as Linux x64 wheels. The artifact contains only +`dotnet.zip`, `javascript.zip`, `python.zip`, and `source.json` with source/build/hash provenance. +Dependency restores and tests execute public source: use reviewed source and isolated, +unprivileged agents without deployment identities, extra secrets, or cached credentials. + +## Minimal protected variable group + +Create `cyot-e2e-config` in ADO Library. Enter values directly there and select the lock +icon for each. Follow your organization's protected-resource guidance and authorize +only the intended pipeline; do not grant open access or unreviewed queue-time overrides. + +| Variable | Value | +| --- | --- | +| `CyotTenantId` | Approved test tenant ID | +| `CyotSubscriptionId` | Approved test subscription ID | +| `CyotCallerClientId` | Application client ID used by the separate test-caller connection | + +The group is referenced only in the opt-in deployment stage. These are identifiers, +not credentials, but secret marking masks exact log matches. Missing/invalid/zero IDs +fail preflight with a fixed error. Expected IDs remain independent of the current +connection, so wrong-tenant/subscription/caller checks are not removed. + +Do not substitute a user object ID, the Function's endpoint application ID, Microsoft's +SAS first-party application, or the provider's outbound identity for the test caller. +This PR does not create the caller, variable group, service connections, or permissions. + +## Deployment inputs + +| Parameter | Value | +| --- | --- | +| `deploymentConnection` | Existing federated ARM deployment connection scoped to the isolated test apps, plus required plan/configuration reads | +| `callerConnection` | Separate federated ARM connection for the test caller, authorized to obtain tokens for all three API audiences | +| `environmentName` | Existing ADO Environment with approvals, restricted access and exclusive lock | +| `resourceGroup` | Existing resource group containing all three test Function Apps | +| `targets` | Exactly three distinct existing apps, one each for `dotnet`, `javascript`, and `python` | + +Each target requires `language`, `appName`, `appType`, `audience`, and `publicKeyBase64`. +Only nonsecret metadata belongs in these parameter fields. `appType` is `functionApp` +for Windows or `functionAppLinux` for Linux; Python must be Linux. An audience is the +API token resource accepted by Easy Auth, not an arbitrary guessed hostname. + +`publicKeyBase64` encodes the UTF-8 public SubjectPublicKeyInfo PEM beginning with +`-----BEGIN PUBLIC KEY-----`, RSA 2048 bits or greater. It must match that app's privately +provisioned decryption key. Public keys are not credentials; base64 is not encryption. + +Existing apps must use Functions v4 and the matching .NET 8 isolated, Node 22, or Python +3.11 stack. Linux requires Premium/Dedicated; this ZIP path rejects Flex and Linux +Consumption. Remote build must be off. All apps must be provider-free: `EPP_PROVIDER_NAME` +absent/empty, with a test-only `EPP_DECRYPTION_KEY_PEM` already configured securely, +preferably through a Key Vault reference. Do not point this pipeline at live-provider apps. + +Require HTTPS and Easy Auth with no excluded paths, the exact approved v1 or v2 tenant +issuer, the intended audience, and the test caller in `allowedApplications`. Configure +required app-role assignments/consent separately. The caller connection must obtain +tokens for the app audiences, not merely ARM. AzurePowerShell initialization can also +require minimal ARM access; do not grant deployment rights to solve that implicitly. +Using different connection names alone does not prove their underlying identities differ; +verify their identities and permissions during onboarding. + +## Validation and privacy + +Preflight checks all targets and ZIP hashes before the first deployment. After deployment, +the caller performs 27 HTTP checks: nine per app covering authenticated SMS/voice evaluation, +missing/invalid tokens, tampered JWE, incomplete context, unsupported type, invalid channel, +and malformed JSON. All requests are direct Function tests; valid envelopes use `mode: 2`. +This is not a SAS-driven authentication flow and does not send through a telephony provider. + +Responses must have the expected HTTP/error or exact nonce/correlation values. The readiness +loop retries only evaluation requests. It does not retry deployments or live sends. +JUnit output records check names, statuses and fixed diagnostic descriptions, not tokens, +nonces, messages, raw provider bodies or exception details. + +Deployment preflight catches errors without printing raw ARM responses/app settings. +ARM and token-acquisition calls suppress warning, verbose, debug and information streams +as well as using fixed failure messages. Regression tests collect output incrementally, +including output emitted before exceptions; catching exceptions alone is not sufficient. +Verified host/audience/public-key metadata stays in an agent temporary file, is never +published as an artifact, and is removed by an always-run cleanup step. Forced agent loss +can prevent cleanup; use ephemeral agents and restrict log/artifact/agent access. Both jobs +request clean workspaces. Standard deployment tasks may still show resource names and +URLs, which must not contain secrets; masking is not a guarantee against all disclosure. +Keep debug/body tracing off, and do not add credentials to parameters, task display names, +output variables, test attachments, package files, or this guide. + +Provider credentials/private keys belong in Key Vault and the Function's managed-identity +access path, not in the pipeline variable group. The pipeline performs app-setting readback +for preflight and must therefore use only isolated test apps. Restrict who may edit pipeline +code or use its protected resources: an authorized malicious task can expose available secrets. + +## Offline checks + +With Python 3.11+ and PowerShell 7.4+ available: + +```powershell +python -m pip install PyYAML +python -m unittest discover -s pipelines/tests -p test_deploy_pipeline.py -v +``` + +These checks parse YAML/PowerShell, validate trigger and secret boundaries, exercise source +selection, run the actual preflight against mocked ARM responses (including privacy markers), +test token-acquisition diagnostic suppression on success and failure, and verify the +RSA/AES-GCM evaluation helpers without cloud access. The seven offline tests do not inspect +real variable-group values or certify Azure task initialization and third-party task logs. + +The PR history and current files were checked for environment-specific identifiers, +invitation links, literal private keys, JWTs and common token/connection-string patterns. +This is a scoped code/content audit, not a guarantee that arbitrary future source packages, +task versions, debug settings or user-supplied parameters cannot expose information. + +1ES template expansion, resource permissions, the full Linux build/package job, and live +Azure deployment still require validation in the configured ADO project. This PR does not +register/run the pipeline or grant authorization. Failed deployment/testing can leave apps +partially updated; retain approved packages and use a reviewed rollback procedure. No automatic +rollback, resource deletion, auto-merge, or branch-policy bypass is included. \ No newline at end of file diff --git a/pipelines/deploy-cyot-e2e.yml b/pipelines/deploy-cyot-e2e.yml new file mode 100644 index 0000000..7c95491 --- /dev/null +++ b/pipelines/deploy-cyot-e2e.yml @@ -0,0 +1,518 @@ +trigger: + batch: true + branches: + include: + - main +pr: none + +parameters: +- name: sourceCommit + displayName: Triggering GitHub commit for build-only, or reviewed full lowercase SHA + type: string + default: triggeringCommit +- name: templateConnection + displayName: Approved 1ES template repository connection + type: string + default: REQUIRED +- name: poolName + displayName: Approved Linux x64 1ES pool + type: string + default: REQUIRED +- name: poolImage + displayName: Linux x64 image with PowerShell 7.4+, Git and Python tool cache + type: string + default: REQUIRED +- name: deployAndTest + displayName: Deploy ALL THREE test apps and run authenticated HTTP tests + type: boolean + default: false +- name: deploymentConnection + displayName: CYOT federated ARM deployment connection + type: string + default: REQUIRED +- name: callerConnection + displayName: Separate federated test-caller connection + type: string + default: REQUIRED +- name: environmentName + displayName: Existing protected ADO environment with approval and exclusive lock + type: string + default: REQUIRED +- name: resourceGroup + displayName: Existing test resource group + type: string + default: REQUIRED +- name: targets + displayName: Three existing apps; audience and base64-encoded PUBLIC RSA PEM for each + type: object + default: + - language: dotnet + appName: REQUIRED + appType: functionApp + audience: REQUIRED + publicKeyBase64: REQUIRED + - language: javascript + appName: REQUIRED + appType: functionApp + audience: REQUIRED + publicKeyBase64: REQUIRED + - language: python + appName: REQUIRED + appType: functionAppLinux + audience: REQUIRED + publicKeyBase64: REQUIRED + +resources: + repositories: + - repository: 1ESPipelineTemplates + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + endpoint: ${{ parameters.templateConnection }} + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates + parameters: + pool: + name: ${{ parameters.poolName }} + image: ${{ parameters.poolImage }} + os: linux + stages: + - stage: Build + jobs: + - job: BuildAll + timeoutInMinutes: 45 + workspace: + clean: all + templateContext: + outputs: + - output: pipelineArtifact + artifactName: cyot-packages + targetPath: $(Build.ArtifactStagingDirectory)/cyot-packages + steps: + - checkout: none + - task: UseDotNet@2 + inputs: + packageType: sdk + version: 8.0.x + - task: UseNode@1 + inputs: + version: 22.x + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.11' + architecture: x64 + - pwsh: | + $ErrorActionPreference = 'Stop' + function Invoke-Checked { + param([string]$Program, [string[]]$Arguments) + & $Program @Arguments + if ($LASTEXITCODE -ne 0) { throw "$Program failed ($LASTEXITCODE)." } + } + if (-not $IsLinux -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne 'X64') { + throw 'Use a clean Linux x64 agent; Python wheels are packaged for Linux x64.' + } + $sourceRef = $env:SOURCE_COMMIT + if ($env:BUILD_REASON -in @('IndividualCI', 'BatchedCI')) { + if ($sourceRef -cne 'triggeringCommit' -or $env:DEPLOY_AND_TEST -ieq 'true' -or + $env:BUILD_SOURCEBRANCH -cne 'refs/heads/main') { + throw 'CI must build the triggering main commit without deployment.' + } + } + if ($sourceRef -ceq 'triggeringCommit') { + if ($env:DEPLOY_AND_TEST -ieq 'true') { throw 'Deployment requires an explicit reviewed full commit SHA.' } + if ($env:BUILD_REPOSITORY_PROVIDER -cne 'GitHub' -or + $env:BUILD_REPOSITORY_NAME -ine 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample') { + throw 'Register this YAML against the EPP GitHub repository.' + } + $sourceRef = $env:BUILD_SOURCEVERSION + } + if ($sourceRef -cnotmatch '^[0-9a-f]{40}$') { + throw 'Use the triggering GitHub commit or a reviewed full lowercase commit SHA.' + } + $source = Join-Path $env:AGENT_TEMPDIRECTORY ('cyot-source-' + $env:BUILD_BUILDID) + if (Test-Path $source) { throw 'Source directory already exists; use a clean agent.' } + New-Item -ItemType Directory $source | Out-Null + Invoke-Checked git @('-C', $source, 'init') + Invoke-Checked git @('-c', 'credential.helper=', '-C', $source, 'fetch', '--depth=1', '--no-tags', '--no-recurse-submodules', 'https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample.git', $sourceRef) + Invoke-Checked git @('-C', $source, 'checkout', '--detach', 'FETCH_HEAD') + $actual = (& git -C $source rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0 -or $actual -cnotmatch '^[0-9a-f]{40}$' -or + $actual -cne $sourceRef) { throw 'Source SHA mismatch.' } + $testOutput = Join-Path $env:AGENT_TEMPDIRECTORY 'cyot-unit' + $artifact = Join-Path $env:BUILD_ARTIFACTSTAGINGDIRECTORY 'cyot-packages' + New-Item -ItemType Directory $testOutput, $artifact | Out-Null + $manifest = @{ commit = $actual; buildId = $env:BUILD_BUILDID; packages = @{} } + foreach ($language in @('dotnet', 'javascript', 'python')) { + $app = Join-Path $source $language + $package = Join-Path $env:AGENT_TEMPDIRECTORY ('cyot-package-' + $language) + if (Test-Path $package) { throw 'Package directory already exists.' } + New-Item -ItemType Directory $package | Out-Null + Push-Location $app + try { + switch ($language) { + dotnet { + Invoke-Checked dotnet @('test', 'tests/Epp.Otp.Tests.csproj', '-c', 'Release', '--logger', 'trx;LogFileName=dotnet.trx', '--results-directory', $testOutput) + [xml]$trx = Get-Content (Join-Path $testOutput 'dotnet.trx') -Raw + $counts = $trx.TestRun.ResultSummary.Counters + if ([int]$counts.total -le 0 -or [int]$counts.passed -ne [int]$counts.total) { throw 'All .NET tests must execute and pass.' } + Invoke-Checked dotnet @('publish', 'dotnet.csproj', '-c', 'Release', '--no-self-contained', '-o', $package) + foreach ($name in @('host.json', 'dotnet.dll', 'functions.metadata', 'worker.config.json', '.azurefunctions')) { + if (-not (Test-Path (Join-Path $package $name))) { throw "Missing publish output: $name" } + } + $metadata = Get-Content (Join-Path $package 'functions.metadata') -Raw | ConvertFrom-Json + if ('SendOtp' -notin @($metadata.name)) { throw 'SendOtp is not registered.' } + } + javascript { + Invoke-Checked npm @('ci', '--no-audit', '--no-fund') + Invoke-Checked node @('--test', '--test-reporter=junit', ('--test-reporter-destination=' + (Join-Path $testOutput 'javascript.xml'))) + Copy-Item host.json, package.json, package-lock.json -Destination $package + Copy-Item src -Destination $package -Recurse + Get-ChildItem $package -Recurse -Force -File | Where-Object { + $_.Name -like 'local.settings*' -or $_.Name -like '.env*' + } | Remove-Item -Force + Push-Location $package + try { Invoke-Checked npm @('ci', '--omit=dev', '--no-audit', '--no-fund') } finally { Pop-Location } + } + python { + if ((& python -c 'import sys; print(sys.version_info[:2] == (3, 11))') -ne 'True') { throw 'Python 3.11 is required.' } + Invoke-Checked python @('-m', 'pip', 'install', 'pytest') + Copy-Item function_app.py, host.json, requirements.txt -Destination $package + Copy-Item src -Destination $package -Recurse + $dependencies = Join-Path $package '.python_packages/lib/site-packages' + Invoke-Checked python @('-m', 'pip', 'install', '-r', 'requirements.txt', '--target', $dependencies, '--platform', 'manylinux2014_x86_64', '--python-version', '3.11', '--implementation', 'cp', '--abi', 'cp311', '--only-binary=:all:', '--no-compile') + $previousPythonPath = $env:PYTHONPATH + try { + $env:PYTHONPATH = $dependencies + $env:PYTHONDONTWRITEBYTECODE = '1' + Invoke-Checked python @('-m', 'pytest', 'tests', ('--junitxml=' + (Join-Path $testOutput 'python.xml'))) + } finally { $env:PYTHONPATH = $previousPythonPath } + } + } + } finally { Pop-Location } + if ($language -ne 'dotnet') { + [xml]$junit = Get-Content (Join-Path $testOutput ($language + '.xml')) -Raw + $cases = @($junit.SelectNodes('//testcase')) + if ($cases.Count -eq 0 -or $junit.SelectNodes('//testcase/failure | //testcase/error | //testcase/skipped').Count -gt 0) { + throw "All $language tests must execute and pass." + } + } + $private = @(Get-ChildItem $package -Recurse -Force -File | Where-Object { + $_.Name -match '^(local\.settings.*|\.env.*)$|\.(pfx|p12|key|publishsettings|pubxml)$' -or + ($_.Extension -eq '.pem' -and (Select-String -LiteralPath $_.FullName -Pattern 'PRIVATE KEY' -Quiet)) + }) + if ($private.Count) { throw 'Package contains private configuration or key material.' } + $zip = Join-Path $artifact ($language + '.zip') + [IO.Compression.ZipFile]::CreateFromDirectory($package, $zip) + $manifest.packages[$language] = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + } + $manifest | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $artifact 'source.json') -Encoding utf8 + Write-Host "All three packages built from $actual." + displayName: Fetch exact source, run all suites, and package all runtimes + env: + SOURCE_COMMIT: ${{ parameters.sourceCommit }} + DEPLOY_AND_TEST: ${{ parameters.deployAndTest }} + GIT_TERMINAL_PROMPT: '0' + - task: PublishTestResults@2 + condition: succeededOrFailed() + inputs: + testResultsFormat: VSTest + testResultsFiles: $(Agent.TempDirectory)/cyot-unit/dotnet.trx + failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true + testRunTitle: CYOT .NET unit tests + - task: PublishTestResults@2 + condition: succeededOrFailed() + inputs: + testResultsFormat: JUnit + testResultsFiles: $(Agent.TempDirectory)/cyot-unit/*.xml + failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true + testRunTitle: CYOT JavaScript and Python unit tests + + - ${{ if eq(parameters.deployAndTest, true) }}: + - stage: DeployAndTest + dependsOn: Build + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'), eq(variables['Build.Reason'], 'Manual')) + variables: + - group: cyot-e2e-config + lockBehavior: sequential + jobs: + - deployment: AllFunctionApps + timeoutInMinutes: 60 + workspace: + clean: all + environment: ${{ parameters.environmentName }} + templateContext: + type: releaseJob + isProduction: false + inputs: + - input: pipelineArtifact + artifactName: cyot-packages + targetPath: $(Pipeline.Workspace)/cyot-packages + strategy: + runOnce: + deploy: + steps: + - checkout: none + - download: none + - task: AzurePowerShell@5 + displayName: Verify ALL targets and artifacts before any deployment + inputs: + azureSubscription: ${{ parameters.deploymentConnection }} + ScriptType: InlineScript + azurePowerShellVersion: LatestVersion + pwsh: true + Inline: | + $ErrorActionPreference = 'Stop' + function Test-DeploymentPreflight { + $context = Get-AzContext + $tenant = [guid]::Empty + $subscription = [guid]::Empty + $caller = [guid]::Empty + if (-not [guid]::TryParse($env:EXPECTED_TENANT, [ref]$tenant) -or + -not [guid]::TryParse($env:EXPECTED_SUBSCRIPTION, [ref]$subscription) -or + -not [guid]::TryParse($env:CALLER_CLIENT_ID, [ref]$caller) -or + $tenant -eq [guid]::Empty -or $subscription -eq [guid]::Empty -or $caller -eq [guid]::Empty) { + throw 'Configure valid identity IDs in the cyot-e2e-config variable group.' + } + if ([guid]$context.Tenant.Id -ne $tenant -or [guid]$context.Subscription.Id -ne $subscription) { throw 'Wrong deployment tenant/subscription.' } + if ($env:DEPLOYMENT_CONNECTION -eq $env:CALLER_CONNECTION) { throw 'Use separate deployment and test-caller connections.' } + $targets = @($env:TARGETS_JSON | ConvertFrom-Json) + if ($targets.Count -ne 3 -or (($targets.language | Sort-Object) -join ',') -ne 'dotnet,javascript,python' -or + @($targets.appName | Sort-Object -Unique).Count -ne 3) { throw 'Three distinct apps, one per language, are required.' } + $artifact = Join-Path $env:PIPELINE_WORKSPACE 'cyot-packages' + $manifest = Get-Content (Join-Path $artifact 'source.json') -Raw | ConvertFrom-Json + if ($manifest.commit -cne $env:SOURCE_COMMIT -or $manifest.buildId -ne $env:BUILD_BUILDID) { throw 'Artifact provenance mismatch.' } + $verified = @() + foreach ($target in $targets) { + if ($target.appName -cnotmatch '^[a-zA-Z0-9][a-zA-Z0-9-]{1,58}[a-zA-Z0-9]$' -or $env:TARGET_GROUP -eq 'REQUIRED' -or + $target.appType -notin @('functionApp','functionAppLinux') -or -not $target.audience -or $target.audience -eq 'REQUIRED') { throw 'Configure every target explicitly.' } + $pem = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($target.publicKeyBase64)) + if ($pem -notmatch '-----BEGIN PUBLIC KEY-----' -or $pem -match 'PRIVATE KEY') { throw 'Only a public SubjectPublicKeyInfo RSA PEM is accepted.' } + $rsa = [Security.Cryptography.RSA]::Create() + try { $rsa.ImportFromPem($pem); if ($rsa.KeySize -lt 2048) { throw 'RSA key is too small.' } } finally { $rsa.Dispose() } + $hash = (Get-FileHash (Join-Path $artifact ($target.language + '.zip')) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($manifest.packages.($target.language) -cne $hash) { throw 'Artifact hash mismatch.' } + $resource = '/subscriptions/' + $subscription + '/resourceGroups/' + [uri]::EscapeDataString($env:TARGET_GROUP) + '/providers/Microsoft.Web/sites/' + [uri]::EscapeDataString($target.appName) + function Read-Arm([string]$Path, [string]$Method = 'GET') { + $response = Invoke-AzRestMethod -Path ($Path + '?api-version=2024-11-01') -Method $Method -ErrorAction Stop -Verbose:$false -Debug:$false 3>$null 4>$null 5>$null 6>$null + if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) { throw 'ARM preflight read failed.' } + return ($response.Content | ConvertFrom-Json) + } + $app = Read-Arm $resource + if ($app.kind -notmatch 'functionapp' -or -not $app.properties.httpsOnly -or + (($app.kind -match 'linux') -ne ($target.appType -eq 'functionAppLinux'))) { throw 'Function App type/HTTPS mismatch.' } + $plan = Read-Arm $app.properties.serverFarmId + if ($plan.sku.name -eq 'FC1') { throw 'This ZIP-deploy path does not support Flex; use an existing non-Flex app.' } + if ($target.appType -eq 'functionAppLinux' -and $plan.sku.name -eq 'Y1') { throw 'Use Linux Premium/Dedicated; Linux Consumption would require a different storage/SAS deployment path.' } + $config = Read-Arm ($resource + '/config/web') + $settings = (Read-Arm ($resource + '/config/appsettings/list') 'POST').properties + $runtime = @{ dotnet = 'dotnet-isolated'; javascript = 'node'; python = 'python' }[$target.language] + $stack = @{ dotnet = 'DOTNET-ISOLATED|8.0'; javascript = 'NODE|22'; python = 'PYTHON|3.11' }[$target.language] + if ($settings.FUNCTIONS_WORKER_RUNTIME -ne $runtime) { throw 'Worker runtime mismatch.' } + if ($settings.FUNCTIONS_EXTENSION_VERSION -notmatch '^~?4(\.|$)') { throw 'The Function host must already use version 4.' } + if ($target.appType -eq 'functionAppLinux') { + if ($config.properties.linuxFxVersion -ne $stack) { throw 'Linux runtime stack mismatch.' } + } elseif ($target.language -eq 'python' -or + ($target.language -eq 'dotnet' -and $config.properties.netFrameworkVersion -ne 'v8.0') -or + ($target.language -eq 'javascript' -and $settings.WEBSITE_NODE_DEFAULT_VERSION -notmatch '^~?22(\.|$)')) { throw 'Windows runtime version mismatch.' } + if ($settings.EPP_PROVIDER_NAME -or -not $settings.EPP_DECRYPTION_KEY_PEM) { throw 'Use a provider-free app with its test-only decryption key already configured.' } + if ($settings.SCM_DO_BUILD_DURING_DEPLOYMENT -eq 'true' -or $settings.ENABLE_ORYX_BUILD -eq 'true') { throw 'Remote build must be disabled for prebuilt ZIPs.' } + $auth = (Read-Arm ($resource + '/config/authsettingsV2/list')).properties + $aad = $auth.identityProviders.azureActiveDirectory + $issuer = [uri]$aad.registration.openIdIssuer + $allowedIssuers = @( + ('https://login.microsoftonline.com/' + $tenant + '/v2.0') + ('https://sts.windows.net/' + $tenant) + ) + if (-not $auth.platform.enabled -or -not $auth.globalValidation.requireAuthentication -or + $auth.globalValidation.unauthenticatedClientAction -ne 'Return401' -or + -not $auth.httpSettings.requireHttps -or + @($auth.globalValidation.excludedPaths).Where({ $_ }).Count -gt 0 -or -not $aad.enabled -or + $issuer.AbsoluteUri.TrimEnd('/') -notin $allowedIssuers -or + $target.audience -notin @($aad.validation.allowedAudiences) -or + $caller.ToString() -notin @($aad.validation.defaultAuthorizationPolicy.allowedApplications)) { throw 'Easy Auth tenant/audience/caller policy does not match the test configuration.' } + $hostname = [string]$app.properties.defaultHostName + if ($hostname -notmatch '^[a-zA-Z0-9.-]+\.azurewebsites\.net$') { throw 'Unexpected public Azure Function hostname.' } + $verified += @{ language = $target.language; url = 'https://' + $hostname + '/api/SendOtp'; audience = $target.audience; publicKeyBase64 = $target.publicKeyBase64 } + } + $verified | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $env:AGENT_TEMPDIRECTORY ('cyot-verified-targets-' + $env:BUILD_BUILDID + '.json')) -Encoding utf8 + Write-Host 'All three targets passed preflight. No settings or identities were modified.' + } + try { Test-DeploymentPreflight } catch { + throw 'Deployment preflight failed. Check protected identity values, target configuration, and artifact provenance. Sensitive details suppressed.' + } + env: + EXPECTED_TENANT: $(CyotTenantId) + EXPECTED_SUBSCRIPTION: $(CyotSubscriptionId) + CALLER_CLIENT_ID: $(CyotCallerClientId) + TARGET_GROUP: ${{ parameters.resourceGroup }} + TARGETS_JSON: ${{ convertToJson(parameters.targets) }} + SOURCE_COMMIT: ${{ parameters.sourceCommit }} + DEPLOYMENT_CONNECTION: ${{ parameters.deploymentConnection }} + CALLER_CONNECTION: ${{ parameters.callerConnection }} + - ${{ each target in parameters.targets }}: + - task: AzureFunctionApp@2 + displayName: Deploy verified ${{ target.language }} ZIP + inputs: + connectedServiceNameARM: ${{ parameters.deploymentConnection }} + appType: ${{ target.appType }} + appName: ${{ target.appName }} + resourceGroupName: ${{ parameters.resourceGroup }} + package: $(Pipeline.Workspace)/cyot-packages/${{ target.language }}.zip + deploymentMethod: zipDeploy + - task: AzurePowerShell@5 + displayName: Test real HTTP authentication and encrypted evaluation on all apps + inputs: + azureSubscription: ${{ parameters.callerConnection }} + ScriptType: InlineScript + azurePowerShellVersion: LatestVersion + pwsh: true + Inline: | + $ErrorActionPreference = 'Stop' + try { + if ([guid](Get-AzContext).Tenant.Id -ne [guid]$env:EXPECTED_TENANT) { throw 'Wrong test-caller tenant.' } + $targets = @(Get-Content (Join-Path $env:AGENT_TEMPDIRECTORY ('cyot-verified-targets-' + $env:BUILD_BUILDID + '.json')) -Raw | ConvertFrom-Json) + } catch { throw 'Test-caller context or verified targets are invalid. Sensitive details suppressed.' } + $results = [Collections.Generic.List[object]]::new() + function Base64Url([byte[]]$Bytes) { [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+','-').Replace('/','_') } + function Decode64([string]$Value) { + $value = $Value.Replace('-','+').Replace('_','/') + [Convert]::FromBase64String($value.PadRight($value.Length + (4 - $value.Length % 4) % 4, '=')) + } + function Encrypt-Context($Context, $Rsa) { + $header = Base64Url ([Text.Encoding]::UTF8.GetBytes('{"alg":"RSA-OAEP-256","enc":"A256GCM"}')) + $key = [Security.Cryptography.RandomNumberGenerator]::GetBytes(32) + $iv = [Security.Cryptography.RandomNumberGenerator]::GetBytes(12) + $plain = [Text.Encoding]::UTF8.GetBytes(($Context | ConvertTo-Json -Compress)) + $cipher = [byte[]]::new($plain.Length) + $tag = [byte[]]::new(16) + $aes = [Security.Cryptography.AesGcm]::new($key, 16) + try { + $aes.Encrypt($iv, $plain, $cipher, $tag, [Text.Encoding]::ASCII.GetBytes($header)) + $wrapped = $Rsa.Encrypt($key, [Security.Cryptography.RSAEncryptionPadding]::OaepSHA256) + return (@($header, (Base64Url $wrapped), (Base64Url $iv), (Base64Url $cipher), (Base64Url $tag)) -join '.') + } finally { $aes.Dispose(); [Array]::Clear($key, 0, $key.Length) } + } + function Envelope([string]$Jwe, [string]$Correlation, [int]$Channel = 1) { + @{ type = 'microsoft.mfa.otpDeliver.v1'; channel = $Channel; mode = 2; ttlSeconds = 60; correlationId = $Correlation; encryptedDeliveryContext = $Jwe } | ConvertTo-Json -Compress + } + function Send-Request([string]$Url, [string]$Body, [string]$Bearer = '') { + $request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::Post, $Url) + try { + $request.Content = [Net.Http.StringContent]::new($Body, [Text.Encoding]::UTF8, 'application/json') + if ($Bearer) { $request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Bearer) } + $response = $client.SendAsync($request).GetAwaiter().GetResult() + try { + $text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + try { $bodyObject = $text | ConvertFrom-Json -ErrorAction Stop } catch { $bodyObject = $null } + return @{ Status = [int]$response.StatusCode; Body = $bodyObject } + } finally { $response.Dispose() } + } catch { return @{ Status = 0; Body = $null } } + finally { $request.Dispose() } + } + function Record([string]$Name, [bool]$Passed, [string]$Detail) { + $results.Add(@{ name = $Name; passed = $Passed; detail = $Detail }) + Write-Host "$Name : $(if ($Passed) { 'PASS' } else { 'FAIL' }) ($Detail)" + } + $handler = [Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $false + $client = [Net.Http.HttpClient]::new($handler) + $client.Timeout = [TimeSpan]::FromSeconds(20) + try { + foreach ($target in $targets) { + $rsa = [Security.Cryptography.RSA]::Create() + $bearer = $null + try { + $rsa.ImportFromPem([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($target.publicKeyBase64))) + $token = Get-AzAccessToken -ResourceUrl $target.audience -ErrorAction Stop -Verbose:$false -Debug:$false 3>$null 4>$null 5>$null 6>$null + $bearer = if ($token.Token -is [Security.SecureString]) { [Net.NetworkCredential]::new('', $token.Token).Password } else { [string]$token.Token } + $claims = [Text.Encoding]::UTF8.GetString((Decode64 $bearer.Split('.')[1])) | ConvertFrom-Json + $clientId = if ($claims.azp) { $claims.azp } else { $claims.appid } + if ($clientId -ne $env:CALLER_CLIENT_ID -or $claims.tid -ne $env:EXPECTED_TENANT) { throw 'Unexpected test-caller claims.' } + $nonce = [guid]::NewGuid().ToString('n') + $correlation = [guid]::NewGuid().ToString('n') + $context = @{ nonce = $nonce; phoneNumber = '+15555550123'; message = 'Synthetic evaluation 123456'; locale = 'en-US' } + $jwe = Encrypt-Context $context $rsa + $valid = Envelope $jwe $correlation + $ready = $false + $last = 0 + for ($attempt = 1; $attempt -le 12; $attempt++) { + $reply = Send-Request $target.url $valid $bearer + $last = $reply.Status + if ($reply.Status -eq 200 -and $reply.Body.nonce -ceq $nonce -and $reply.Body.correlationId -ceq $correlation) { $ready = $true; break } + if ($reply.Status -notin @(0, 401, 403, 404, 429, 500, 502, 503, 504)) { break } + if ($attempt -lt 12) { [Threading.Tasks.Task]::Delay(5000).GetAwaiter().GetResult() } + } + Record "$($target.language).evaluation_sms" $ready "HTTP $last; matching nonce/correlation required" + if (-not $ready) { continue } + $reply = Send-Request $target.url $valid + Record "$($target.language).anonymous_rejected" ($reply.Status -eq 401) "HTTP $($reply.Status)" + $reply = Send-Request $target.url $valid 'invalid-token' + Record "$($target.language).invalid_token_rejected" ($reply.Status -eq 401) "HTTP $($reply.Status)" + $reply = Send-Request $target.url (Envelope $jwe $correlation 2) $bearer + Record "$($target.language).evaluation_voice" ($reply.Status -eq 200 -and $reply.Body.nonce -ceq $nonce -and $reply.Body.correlationId -ceq $correlation) "HTTP $($reply.Status); matching nonce/correlation required" + $parts = $jwe.Split('.') + $tag = Decode64 $parts[4] + $tag[0] = $tag[0] -bxor 1 + $parts[4] = Base64Url $tag + $incomplete = Encrypt-Context @{ nonce = ''; phoneNumber = '+15555550123'; message = 'Synthetic evaluation' } $rsa + $badType = $valid | ConvertFrom-Json + $badType.type = 'invalid' + $badChannel = $valid | ConvertFrom-Json + $badChannel.channel = $true + $invalidCases = @( + @{ name = 'tampered_jwe'; body = (Envelope ($parts -join '.') $correlation); error = 'decryption_failed' }, + @{ name = 'incomplete_context'; body = (Envelope $incomplete $correlation); error = 'bad_request' }, + @{ name = 'unsupported_type'; body = ($badType | ConvertTo-Json -Compress); error = 'bad_request' }, + @{ name = 'invalid_channel'; body = ($badChannel | ConvertTo-Json -Compress); error = 'bad_request' }, + @{ name = 'malformed_json'; body = '{'; error = 'bad_request' } + ) + foreach ($case in $invalidCases) { + $reply = Send-Request $target.url $case.body $bearer + $passed = $reply.Status -eq 400 -and $reply.Body.error -ceq $case.error -and + -not ($reply.Body.PSObject.Properties.Name -contains 'nonce') + Record "$($target.language).$($case.name)" $passed "HTTP $($reply.Status); fixed error and no nonce required" + } + } catch { + Record "$($target.language).harness" $false 'Credential, public key, or harness failure; sensitive exception details suppressed' + } finally { $rsa.Dispose(); $bearer = $null; $token = $null } + } + } finally { + $client.Dispose() + $failed = @($results | Where-Object { -not $_.passed }).Count + $output = Join-Path $env:PIPELINE_WORKSPACE 'cyot-http-results.xml' + $writer = [Xml.XmlWriter]::Create($output, [Xml.XmlWriterSettings]@{ Indent = $true }) + try { + $writer.WriteStartElement('testsuite') + $writer.WriteAttributeString('name', 'CYOT deployed Function HTTP tests') + $writer.WriteAttributeString('tests', [string]$results.Count) + $writer.WriteAttributeString('failures', [string]$failed) + foreach ($result in $results) { + $writer.WriteStartElement('testcase'); $writer.WriteAttributeString('name', $result.name) + if (-not $result.passed) { $writer.WriteElementString('failure', $result.detail) } + $writer.WriteEndElement() + } + $writer.WriteEndElement() + } finally { $writer.Dispose() } + } + if ($failed -gt 0 -or $results.Count -ne 27) { throw 'Deployed HTTP validation failed or did not execute all 27 checks. See the ADO Tests tab.' } + env: + EXPECTED_TENANT: $(CyotTenantId) + CALLER_CLIENT_ID: $(CyotCallerClientId) + - task: PublishTestResults@2 + condition: succeededOrFailed() + inputs: + testResultsFormat: JUnit + testResultsFiles: $(Pipeline.Workspace)/cyot-http-results.xml + failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true + testRunTitle: CYOT deployed HTTP tests (no provider delivery) + - pwsh: | + $path = Join-Path $env:AGENT_TEMPDIRECTORY ('cyot-verified-targets-' + $env:BUILD_BUILDID + '.json') + try { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Force -ErrorAction Stop } + } catch { throw 'Temporary target metadata cleanup failed.' } + displayName: Remove temporary target metadata + condition: always() \ No newline at end of file diff --git a/pipelines/tests/test_deploy_pipeline.py b/pipelines/tests/test_deploy_pipeline.py new file mode 100644 index 0000000..c3e5561 --- /dev/null +++ b/pipelines/tests/test_deploy_pipeline.py @@ -0,0 +1,308 @@ +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +PIPELINE = ROOT / "pipelines" / "deploy-cyot-e2e.yml" + + +def scripts_in(value): + if isinstance(value, dict): + for key, item in value.items(): + if key in ("pwsh", "Inline") and isinstance(item, str): + yield item + else: + yield from scripts_in(item) + elif isinstance(value, list): + for item in value: + yield from scripts_in(item) + + +class PipelineTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.text = PIPELINE.read_text(encoding="utf-8") + cls.pipeline = yaml.safe_load(cls.text) + cls.scripts = list(scripts_in(cls.pipeline)) + + def powershell(self, program, stdin="", extra_env=None): + environment = os.environ.copy() + environment.update(extra_env or {}) + result = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", program], + input=stdin, capture_output=True, text=True, env=environment, timeout=90, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + def test_main_trigger_and_manual_deployment_boundary(self): + self.assertEqual(self.pipeline["trigger"], { + "batch": True, "branches": {"include": ["main"]}, + }) + self.assertEqual(self.pipeline["pr"], "none") + parameters = {item["name"]: item for item in self.pipeline["parameters"]} + self.assertTrue(all("default" in item for item in parameters.values())) + self.assertEqual(parameters["sourceCommit"]["default"], "triggeringCommit") + self.assertIs(parameters["deployAndTest"]["default"], False) + self.assertFalse({"tenantId", "subscriptionId", "callerClientId"} & parameters.keys()) + stages = self.pipeline["extends"]["parameters"]["stages"] + deployment = stages[1]["${{ if eq(parameters.deployAndTest, true) }}"][0] + self.assertIn("eq(variables['Build.SourceBranch'], 'refs/heads/main')", deployment["condition"]) + self.assertIn("eq(variables['Build.Reason'], 'Manual')", deployment["condition"]) + self.assertNotIn("variables", self.pipeline) + self.assertNotIn("variables", stages[0]) + self.assertEqual(deployment["variables"], [{"group": "cyot-e2e-config"}]) + self.assertEqual(deployment["lockBehavior"], "sequential") + build_definition = yaml.safe_dump(stages[0]) + for private_reference in ("CyotTenantId", "CyotSubscriptionId", "CyotCallerClientId", "AzurePowerShell@", "AzureFunctionApp@"): + self.assertNotIn(private_reference, build_definition) + outputs = stages[0]["jobs"][0]["templateContext"]["outputs"] + self.assertEqual(outputs, [{"output": "pipelineArtifact", "artifactName": "cyot-packages", + "targetPath": "$(Build.ArtifactStagingDirectory)/cyot-packages"}]) + + def test_identity_values_and_credentials_are_not_in_source(self): + self.assertFalse(re.search(r"\b[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\b", self.text)) + self.assertNotRegex(self.text, r"-----BEGIN (?:RSA |EC |ENCRYPTED )?PRIVATE KEY-----") + self.assertNotIn("$(System.AccessToken)", self.text) + self.assertNotIn("persistCredentials: true", self.text) + self.assertIn("EXPECTED_TENANT: $(CyotTenantId)", self.text) + self.assertIn("EXPECTED_SUBSCRIPTION: $(CyotSubscriptionId)", self.text) + self.assertIn("CALLER_CLIENT_ID: $(CyotCallerClientId)", self.text) + self.assertNotIn("Write-Host $bearer", self.text) + self.assertNotIn("Write-Host $settings", self.text) + self.assertNotIn("Write-Host $_", self.text) + self.assertIn("https://github.com/Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample.git", self.text) + self.assertIn("condition: always()", self.text) + + def test_all_powershell_blocks_parse(self): + self.assertEqual(len(self.scripts), 4) + parser = r""" +$tokens = $null +$errors = $null +[System.Management.Automation.Language.Parser]::ParseInput( + [Console]::In.ReadToEnd(), [ref]$tokens, [ref]$errors) | Out-Null +if ($errors.Count) { throw ($errors.Message -join '; ') } +""" + for script in self.scripts: + self.powershell(parser, script) + + def test_source_selection(self): + guard = "$sourceRef = $env:SOURCE_COMMIT" + self.scripts[0].split( + "$sourceRef = $env:SOURCE_COMMIT", 1)[1].split("$source = Join-Path", 1)[0] + self.powershell(r""" +$guard = [scriptblock]::Create([Console]::In.ReadToEnd()) +$cases = @( + @{ source='triggeringCommit'; deploy='False'; allowed=$true }, + @{ source='triggeringCommit'; deploy='True'; allowed=$false }, + @{ source=('a' * 40); deploy='True'; allowed=$true }, + @{ source=('a' * 40); deploy='False'; allowed=$true }, + @{ source='main'; deploy='False'; allowed=$false }, + @{ source='refs/heads/main'; deploy='False'; allowed=$false }, + @{ source='--upload-pack=bad'; deploy='False'; allowed=$false }, + @{ source=('A' * 40); deploy='True'; allowed=$false }, + @{ source=''; deploy='True'; allowed=$false }, + @{ source='triggeringCommit'; deploy='False'; reason='IndividualCI'; allowed=$true }, + @{ source='triggeringCommit'; deploy='False'; reason='BatchedCI'; allowed=$true }, + @{ source=('a' * 40); deploy='False'; reason='IndividualCI'; allowed=$false }, + @{ source='triggeringCommit'; deploy='True'; reason='IndividualCI'; allowed=$false }, + @{ source='triggeringCommit'; deploy='False'; reason='IndividualCI'; branch='refs/heads/other'; allowed=$false }, + @{ source='triggeringCommit'; deploy='False'; provider='TfsGit'; allowed=$false }, + @{ source='triggeringCommit'; deploy='False'; repository='someone/fork'; allowed=$false }, + @{ source='triggeringCommit'; deploy='False'; sha='invalid'; allowed=$false } +) +foreach ($case in $cases) { + $env:SOURCE_COMMIT = $case.source + $env:DEPLOY_AND_TEST = $case.deploy + $env:BUILD_REASON = $(if ($case.reason) { $case.reason } else { 'Manual' }) + $env:BUILD_SOURCEBRANCH = $(if ($case.branch) { $case.branch } else { 'refs/heads/main' }) + $env:BUILD_REPOSITORY_PROVIDER = $(if ($case.provider) { $case.provider } else { 'GitHub' }) + $env:BUILD_REPOSITORY_NAME = $(if ($case.repository) { $case.repository } else { 'Azure-Samples/ExternalPhoneProvider-AzureFunction-Sample' }) + $env:BUILD_SOURCEVERSION = $(if ($case.sha) { $case.sha } else { 'b' * 40 }) + $accepted = $true + try { . $guard } catch { $accepted = $false } + if ($accepted -ne $case.allowed) { throw 'Source guard mismatch.' } + if ($accepted) { + $expected = $(if ($case.source -eq 'triggeringCommit') { $env:BUILD_SOURCEVERSION } else { $case.source }) + if ($sourceRef -cne $expected) { throw 'Wrong commit selected.' } + } +} +""", guard) + + def test_preflight_and_cleanup_with_mock_arm(self): + with tempfile.TemporaryDirectory() as temporary: + self.powershell(r""" +$ErrorActionPreference = 'Stop' +$scripts = [Console]::In.ReadToEnd() | ConvertFrom-Json +$preflight = [scriptblock]::Create($scripts.preflight) +$cleanup = [scriptblock]::Create($scripts.cleanup) +$env:AGENT_TEMPDIRECTORY = $env:TEST_DIRECTORY +$env:PIPELINE_WORKSPACE = $env:TEST_DIRECTORY +$env:BUILD_BUILDID = '123' +$env:SOURCE_COMMIT = 'a' * 40 +$env:TARGET_GROUP = 'isolated-test-group' +$artifact = Join-Path $env:TEST_DIRECTORY 'cyot-packages' +New-Item -ItemType Directory $artifact | Out-Null +$manifest = @{ commit=$env:SOURCE_COMMIT; buildId=$env:BUILD_BUILDID; packages=@{} } +$rsa = [Security.Cryptography.RSA]::Create(2048) +try { $publicKey = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($rsa.ExportSubjectPublicKeyInfoPem())) } +finally { $rsa.Dispose() } +$targets = foreach ($language in @('dotnet', 'javascript', 'python')) { + $zip = Join-Path $artifact ($language + '.zip') + [IO.File]::WriteAllText($zip, 'offline fixture') + $manifest.packages[$language] = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + @{ language=$language; appName=('unit-' + $language); appType='functionAppLinux'; audience='api://unit-test'; publicKeyBase64=$publicKey } +} +$manifest | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $artifact 'source.json') +function Get-AzContext { + @{ Tenant=@{ Id='11111111-1111-4111-8111-111111111111' }; Subscription=@{ Id='22222222-2222-4222-8222-222222222222' } } +} +function Invoke-AzRestMethod { + [CmdletBinding()] + param([string]$Path, [string]$Method) + if ($script:scenario -eq 'resource-error') { + Write-Warning 'PRIVATE-SENTINEL' + Write-Verbose 'PRIVATE-SENTINEL' + Write-Debug 'PRIVATE-SENTINEL' + Write-Information 'PRIVATE-SENTINEL' + throw 'PRIVATE-SENTINEL' + } + $pathWithoutQuery = $Path.Split('?')[0] + $language = [regex]::Match($pathWithoutQuery, '/sites/unit-([^/]+)').Groups[1].Value + if ($pathWithoutQuery.EndsWith('/config/authsettingsV2/list')) { + $issuer = 'https://login.microsoftonline.com/' + $env:EXPECTED_TENANT + '/v2.0' + if ($script:scenario -eq 'valid-v1') { $issuer = 'https://sts.windows.net/' + $env:EXPECTED_TENANT + '/' } + if ($script:scenario -eq 'bad-issuer') { $issuer = 'https://login.microsoftonline.com/extra/' + $env:EXPECTED_TENANT + '/v2.0' } + $body = @{ properties=@{ + platform=@{ enabled=($script:scenario -ne 'no-auth') } + globalValidation=@{ requireAuthentication=$true; unauthenticatedClientAction='Return401'; excludedPaths=@() } + httpSettings=@{ requireHttps=($script:scenario -ne 'no-https') } + identityProviders=@{ azureActiveDirectory=@{ + enabled=$true; registration=@{ openIdIssuer=$issuer } + validation=@{ allowedAudiences=@('api://unit-test'); defaultAuthorizationPolicy=@{ + allowedApplications=@($(if ($script:scenario -eq 'no-allowlist') { 'other' } else { $env:CALLER_CLIENT_ID })) + } } + } } + } } + } elseif ($pathWithoutQuery.EndsWith('/config/appsettings/list')) { + if ($Method -ne 'POST') { throw 'Incorrect settings read method.' } + $body = @{ properties=@{ + FUNCTIONS_WORKER_RUNTIME=@{ dotnet='dotnet-isolated'; javascript='node'; python='python' }[$language] + FUNCTIONS_EXTENSION_VERSION='~4' + EPP_DECRYPTION_KEY_PEM='PRIVATE-SENTINEL' + EPP_PROVIDER_NAME=$(if ($script:scenario -eq 'live-provider') { 'live' } else { '' }) + SCM_DO_BUILD_DURING_DEPLOYMENT=$(if ($script:scenario -eq 'remote-build') { 'true' } else { 'false' }) + } } + } elseif ($pathWithoutQuery.EndsWith('/config/web')) { + $body = @{ properties=@{ linuxFxVersion=@{ dotnet='DOTNET-ISOLATED|8.0'; javascript='NODE|22'; python='PYTHON|3.11' }[$language] } } + } elseif ($pathWithoutQuery -eq '/mock-plan') { + $body = @{ sku=@{ name=$(if ($script:scenario -eq 'flex') { 'FC1' } else { 'EP1' }) } } + } elseif ($pathWithoutQuery -match '/sites/unit-(dotnet|javascript|python)$') { + $body = @{ kind='functionapp,linux'; properties=@{ + httpsOnly=$true; serverFarmId='/mock-plan'; defaultHostName=('unit-' + $language + '.azurewebsites.net') + } } + } else { throw 'Unexpected ARM path.' } + @{ StatusCode=200; Content=($body | ConvertTo-Json -Depth 12) } +} +$scenarios = @('valid-v2', 'valid-v1', 'wrong-tenant', 'missing-id', 'zero-id', 'same-connection', + 'bad-issuer', 'no-auth', 'no-https', 'no-allowlist', 'live-provider', 'flex', 'remote-build', + 'resource-error', 'invalid-key', 'bad-source', 'invalid-json', 'tampered-hash') +foreach ($script:scenario in $scenarios) { + $env:EXPECTED_TENANT = '11111111-1111-4111-8111-111111111111' + $env:EXPECTED_SUBSCRIPTION = '22222222-2222-4222-8222-222222222222' + $env:CALLER_CLIENT_ID = '33333333-3333-4333-8333-333333333333' + $env:DEPLOYMENT_CONNECTION = 'deployment' + $env:CALLER_CONNECTION = 'caller' + $env:SOURCE_COMMIT = 'a' * 40 + $env:TARGETS_JSON = $targets | ConvertTo-Json -Depth 5 + if ($scenario -eq 'wrong-tenant') { $env:EXPECTED_TENANT = '44444444-4444-4444-8444-444444444444' } + if ($scenario -eq 'missing-id') { $env:CALLER_CLIENT_ID = '$(UnresolvedVariable)' } + if ($scenario -eq 'zero-id') { $env:CALLER_CLIENT_ID = [guid]::Empty.ToString() } + if ($scenario -eq 'same-connection') { $env:CALLER_CONNECTION = $env:DEPLOYMENT_CONNECTION } + if ($scenario -eq 'invalid-key') { $env:TARGETS_JSON = $env:TARGETS_JSON.Replace($publicKey, 'PRIVATE-SENTINEL') } + if ($scenario -eq 'bad-source') { $env:SOURCE_COMMIT = 'b' * 40 } + if ($scenario -eq 'invalid-json') { $env:TARGETS_JSON = 'PRIVATE-SENTINEL' } + if ($scenario -eq 'tampered-hash') { [IO.File]::WriteAllText((Join-Path $artifact 'dotnet.zip'), 'tampered') } + $accepted = $true + $message = '' + $output = [Collections.Generic.List[object]]::new() + try { & $preflight 3>&1 4>&1 5>&1 6>&1 | ForEach-Object { $output.Add($_) } } catch { $accepted = $false; $message = $_.Exception.Message } + if ($accepted -ne ($scenario -in @('valid-v1', 'valid-v2'))) { throw "Preflight result mismatch: $scenario" } + if (-not $accepted -and $message -cne 'Deployment preflight failed. Check protected identity values, target configuration, and artifact provenance. Sensitive details suppressed.') { + throw 'Preflight failure was not sanitized.' + } + if (($output -join '') -match 'PRIVATE-SENTINEL') { throw 'Private data reached output.' } + $targetFile = Join-Path $env:AGENT_TEMPDIRECTORY ('cyot-verified-targets-' + $env:BUILD_BUILDID + '.json') + if ($accepted -and @((Get-Content $targetFile -Raw | ConvertFrom-Json)).Count -ne 3) { throw 'Verified target count mismatch.' } + & $cleanup + if (Test-Path $targetFile) { throw 'Target metadata retained.' } +} +""", json.dumps({"preflight": self.scripts[1], "cleanup": self.scripts[3]}), + {"TEST_DIRECTORY": temporary}) + + def test_token_acquisition_diagnostics_are_not_logged(self): + token_line = next(line.strip() for line in self.scripts[2].splitlines() + if "$token = Get-AzAccessToken" in line) + self.powershell(r""" +$ErrorActionPreference = 'Stop' +$acquire = [scriptblock]::Create([Console]::In.ReadToEnd()) +$target = @{ audience='api://unit-test' } +function Get-AzAccessToken { + [CmdletBinding()] + param([string]$ResourceUrl) + Write-Warning 'PRIVATE-TOKEN-MARKER' + Write-Verbose 'PRIVATE-TOKEN-MARKER' -Verbose + Write-Debug 'PRIVATE-TOKEN-MARKER' -Debug + Write-Information 'PRIVATE-TOKEN-MARKER' -InformationAction Continue + if ($script:failAcquisition) { throw 'PRIVATE-TOKEN-MARKER' } + [pscustomobject]@{ Token='PRIVATE-TOKEN-MARKER' } +} +foreach ($script:failAcquisition in @($false, $true)) { + $output = [Collections.Generic.List[object]]::new() + $failed = $false + try { & $acquire 3>&1 4>&1 5>&1 6>&1 | ForEach-Object { $output.Add($_) } } + catch { $failed = $true } + if ($failed -ne $script:failAcquisition) { throw 'Acquisition result changed.' } + if (($output -join '') -match 'PRIVATE-TOKEN-MARKER') { throw 'Token diagnostics reached output.' } +} +""", token_line) + + def test_encrypted_evaluation_helpers(self): + helpers = "function Base64Url" + self.scripts[2].split( + "function Base64Url", 1)[1].split("$handler =", 1)[0] + self.powershell(r""" +$ErrorActionPreference = 'Stop' +. ([scriptblock]::Create([Console]::In.ReadToEnd())) +$rsa = [Security.Cryptography.RSA]::Create(2048) +try { + $context = @{ nonce='unit-nonce'; phoneNumber='+15555550123'; message='Synthetic evaluation 123456' } + $jwe = Encrypt-Context $context $rsa + $parts = $jwe.Split('.') + if ($parts.Count -ne 5) { throw 'JWE segment count mismatch.' } + $header = [Text.Encoding]::UTF8.GetString((Decode64 $parts[0])) | ConvertFrom-Json + if ($header.alg -ne 'RSA-OAEP-256' -or $header.enc -ne 'A256GCM') { throw 'JWE algorithm mismatch.' } + $key = $rsa.Decrypt((Decode64 $parts[1]), [Security.Cryptography.RSAEncryptionPadding]::OaepSHA256) + $aes = [Security.Cryptography.AesGcm]::new($key, 16) + try { + $cipher = Decode64 $parts[3] + $plain = [byte[]]::new($cipher.Length) + $aes.Decrypt((Decode64 $parts[2]), $cipher, (Decode64 $parts[4]), $plain, [Text.Encoding]::ASCII.GetBytes($parts[0])) + $decoded = [Text.Encoding]::UTF8.GetString($plain) | ConvertFrom-Json + if ($decoded.nonce -cne $context.nonce -or $decoded.message -cne $context.message) { throw 'JWE round-trip failed.' } + foreach ($channel in @(1, 2)) { + $envelope = Envelope $jwe 'unit-correlation' $channel | ConvertFrom-Json + if ($envelope.mode -ne 2 -or $envelope.channel -ne $channel -or $envelope.encryptedDeliveryContext -cne $jwe) { throw 'Evaluation envelope mismatch.' } + } + } finally { $aes.Dispose(); [Array]::Clear($key, 0, $key.Length) } +} finally { $rsa.Dispose() } +""", helpers) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file