This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter()]
|
||||
[ValidatePattern('^https://')]
|
||||
[string]$BaseUrl = 'https://pulse.example.com',
|
||||
|
||||
[Parameter()]
|
||||
[ValidatePattern('^http://')]
|
||||
[string]$HttpUrl = 'http://pulse.example.com',
|
||||
|
||||
[Parameter()]
|
||||
[ValidatePattern('^https://')]
|
||||
[string]$ExpectedOIDCIssuer = 'https://auth.nuklearrabbit.com/application/o/itworx-pulse',
|
||||
|
||||
[Parameter()]
|
||||
[ValidateRange(1, 30)]
|
||||
[int]$TimeoutSeconds = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$base = [uri]$BaseUrl
|
||||
$plain = [uri]$HttpUrl
|
||||
$issuer = [uri]$ExpectedOIDCIssuer
|
||||
if (-not [string]::IsNullOrEmpty($base.UserInfo) -or -not [string]::IsNullOrEmpty($plain.UserInfo) -or -not [string]::IsNullOrEmpty($issuer.UserInfo)) {
|
||||
throw 'Production smoke URLs may not contain embedded credentials.'
|
||||
}
|
||||
|
||||
function Invoke-NoRedirect([uri]$Uri) {
|
||||
$handler = [System.Net.Http.HttpClientHandler]::new()
|
||||
$handler.AllowAutoRedirect = $false
|
||||
$client = [System.Net.Http.HttpClient]::new($handler)
|
||||
$client.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds)
|
||||
try {
|
||||
$response = $client.GetAsync($Uri).GetAwaiter().GetResult()
|
||||
$headers = @{}
|
||||
foreach ($header in $response.Headers) {
|
||||
$headers[$header.Key] = $header.Value -join ', '
|
||||
}
|
||||
foreach ($header in $response.Content.Headers) {
|
||||
$headers[$header.Key] = $header.Value -join ', '
|
||||
}
|
||||
[pscustomobject]@{
|
||||
StatusCode = [int]$response.StatusCode
|
||||
Headers = $headers
|
||||
Content = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
$handler.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Probe([string]$Path) {
|
||||
Invoke-NoRedirect ([uri]($BaseUrl.TrimEnd('/') + $Path))
|
||||
}
|
||||
|
||||
function Assert-Status($Response, [int[]]$Expected, [string]$Label) {
|
||||
if ($Expected -notcontains [int]$Response.StatusCode) {
|
||||
throw "$Label returned HTTP $($Response.StatusCode); expected $($Expected -join '/')"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ResponseText($Response) {
|
||||
if ($Response.Content -is [byte[]]) {
|
||||
return [System.Text.Encoding]::UTF8.GetString([byte[]]$Response.Content)
|
||||
}
|
||||
return [string]$Response.Content
|
||||
}
|
||||
|
||||
Write-Host '== HTTPS health and security headers ==' -ForegroundColor Cyan
|
||||
$health = Invoke-Probe '/healthz'
|
||||
Assert-Status $health @(200) 'HTTPS /healthz'
|
||||
if ((Get-ResponseText $health).Trim() -ne 'ok' -or $health.Headers.'Content-Type' -notmatch '^text/plain') {
|
||||
throw 'HTTPS /healthz must return exact text/plain ok content.'
|
||||
}
|
||||
foreach ($header in @('Strict-Transport-Security', 'Content-Security-Policy', 'X-Content-Type-Options', 'X-Frame-Options')) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$health.Headers.$header)) { throw "HTTPS response is missing $header." }
|
||||
}
|
||||
if ([string]$health.Headers.'Strict-Transport-Security' -notmatch 'max-age=\d+') {
|
||||
throw 'HSTS does not contain a max-age directive.'
|
||||
}
|
||||
|
||||
$ready = Invoke-Probe '/readyz'
|
||||
Assert-Status $ready @(200) 'HTTPS /readyz'
|
||||
if ($ready.Headers.'Content-Type' -notmatch '^text/plain' -or (Get-ResponseText $ready) -match '<!doctype html') {
|
||||
throw 'HTTPS /readyz is not genuine text/plain API readiness.'
|
||||
}
|
||||
|
||||
Write-Host '== HTTP to HTTPS enforcement ==' -ForegroundColor Cyan
|
||||
$redirect = Invoke-NoRedirect ([uri]($HttpUrl.TrimEnd('/') + '/healthz'))
|
||||
Assert-Status $redirect @(301, 302, 307, 308) 'HTTP /healthz'
|
||||
$location = [uri]::new($plain, [string]$redirect.Headers.Location)
|
||||
if ($location.Scheme -ne 'https' -or $location.Host -ne $base.Host) {
|
||||
throw "HTTP redirect escapes the production HTTPS host: $location"
|
||||
}
|
||||
|
||||
Write-Host '== public exposure boundary ==' -ForegroundColor Cyan
|
||||
foreach ($path in @('/metrics', '/debug/pprof/')) {
|
||||
$response = Invoke-Probe $path
|
||||
Assert-Status $response @(404) $path
|
||||
if ((Get-ResponseText $response) -match '<!doctype html') { throw "$path fell through to the SPA." }
|
||||
}
|
||||
foreach ($path in @('/api/v1/system/status', '/api/v1/system/metrics', '/api/v1/system/diagnostics')) {
|
||||
$response = Invoke-Probe $path
|
||||
Assert-Status $response @(401) $path
|
||||
}
|
||||
|
||||
Write-Host '== OIDC entrypoint ==' -ForegroundColor Cyan
|
||||
$discovery = Invoke-NoRedirect ([uri]($ExpectedOIDCIssuer.TrimEnd('/') + '/.well-known/openid-configuration'))
|
||||
Assert-Status $discovery @(200) 'OIDC discovery'
|
||||
$provider = $discovery.Content | ConvertFrom-Json
|
||||
if (([string]$provider.issuer).TrimEnd('/') -ne $ExpectedOIDCIssuer.TrimEnd('/')) {
|
||||
throw "OIDC discovery returned an unexpected issuer: $($provider.issuer)"
|
||||
}
|
||||
$authorizationEndpoint = [uri][string]$provider.authorization_endpoint
|
||||
if ($authorizationEndpoint.Scheme -ne 'https' -or $authorizationEndpoint.Host -ne $issuer.Host) {
|
||||
throw "OIDC discovery returned an unsafe authorization endpoint: $authorizationEndpoint"
|
||||
}
|
||||
$login = Invoke-Probe '/auth/login'
|
||||
Assert-Status $login @(302, 303, 307) '/auth/login'
|
||||
$loginLocation = [uri]::new($base, [string]$login.Headers.Location)
|
||||
if ($loginLocation.Scheme -ne $authorizationEndpoint.Scheme -or $loginLocation.Host -ne $authorizationEndpoint.Host -or $loginLocation.AbsolutePath -ne $authorizationEndpoint.AbsolutePath) {
|
||||
throw "OIDC login redirect does not target the expected issuer: $loginLocation"
|
||||
}
|
||||
|
||||
Write-Host "PRODUCTION PUBLIC SMOKE: PASS ($BaseUrl)" -ForegroundColor Green
|
||||
Reference in New Issue
Block a user