part #2
This commit is contained in:
171
devops/compose/.env
Normal file
171
devops/compose/.env
Normal file
@@ -0,0 +1,171 @@
|
||||
# =============================================================================
|
||||
# STELLA OPS ENVIRONMENT CONFIGURATION
|
||||
# =============================================================================
|
||||
# Main environment template for docker-compose.stella-ops.yml
|
||||
# Copy to .env and customize for your deployment.
|
||||
#
|
||||
# Usage:
|
||||
# cp env/stellaops.env.example .env
|
||||
# docker compose -f docker-compose.stella-ops.yml up -d
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# INFRASTRUCTURE
|
||||
# =============================================================================
|
||||
|
||||
# PostgreSQL Database
|
||||
POSTGRES_USER=stellaops
|
||||
POSTGRES_PASSWORD=stellaops
|
||||
POSTGRES_DB=stellaops_platform
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Valkey (Redis-compatible cache and messaging)
|
||||
VALKEY_PORT=6379
|
||||
|
||||
# RustFS Object Storage
|
||||
RUSTFS_HTTP_PORT=8080
|
||||
|
||||
# =============================================================================
|
||||
# CORE SERVICES
|
||||
# =============================================================================
|
||||
|
||||
# Authority (OAuth2/OIDC)
|
||||
AUTHORITY_ISSUER=https://authority.stella-ops.local
|
||||
AUTHORITY_PORT=8440
|
||||
AUTHORITY_OFFLINE_CACHE_TOLERANCE=00:30:00
|
||||
|
||||
# Signer
|
||||
SIGNER_POE_INTROSPECT_URL=http://authority.stella-ops.local/.well-known/openid-configuration
|
||||
SIGNER_PORT=8441
|
||||
|
||||
# Attestor
|
||||
ATTESTOR_PORT=8442
|
||||
|
||||
# Issuer Directory
|
||||
ISSUER_DIRECTORY_PORT=8447
|
||||
ISSUER_DIRECTORY_SEED_CSAF=true
|
||||
|
||||
# Concelier
|
||||
CONCELIER_PORT=8445
|
||||
|
||||
# Notify
|
||||
NOTIFY_WEB_PORT=8446
|
||||
|
||||
# Web UI
|
||||
UI_PORT=8443
|
||||
|
||||
# =============================================================================
|
||||
# SCANNER CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
SCANNER_WEB_PORT=8444
|
||||
|
||||
# Queue configuration (Valkey only - NATS removed)
|
||||
SCANNER__QUEUE__BROKER=valkey://cache.stella-ops.local:6379
|
||||
|
||||
# Event streaming
|
||||
SCANNER_EVENTS_ENABLED=false
|
||||
SCANNER_EVENTS_DRIVER=valkey
|
||||
SCANNER_EVENTS_DSN=cache.stella-ops.local:6379
|
||||
SCANNER_EVENTS_STREAM=stella.events
|
||||
SCANNER_EVENTS_PUBLISH_TIMEOUT_SECONDS=5
|
||||
SCANNER_EVENTS_MAX_STREAM_LENGTH=10000
|
||||
|
||||
# Surface cache configuration
|
||||
SCANNER_SURFACE_FS_ENDPOINT=http://s3.stella-ops.local
|
||||
SCANNER_SURFACE_FS_BUCKET=surface-cache
|
||||
SCANNER_SURFACE_CACHE_ROOT=/var/lib/stellaops/surface
|
||||
SCANNER_SURFACE_CACHE_QUOTA_MB=4096
|
||||
SCANNER_SURFACE_PREFETCH_ENABLED=false
|
||||
SCANNER_SURFACE_TENANT=default
|
||||
SCANNER_SURFACE_FEATURES=
|
||||
SCANNER_SURFACE_SECRETS_PROVIDER=file
|
||||
SCANNER_SURFACE_SECRETS_NAMESPACE=
|
||||
SCANNER_SURFACE_SECRETS_ROOT=/etc/stellaops/secrets
|
||||
SCANNER_SURFACE_SECRETS_FALLBACK_PROVIDER=
|
||||
SCANNER_SURFACE_SECRETS_ALLOW_INLINE=false
|
||||
SURFACE_SECRETS_HOST_PATH=./offline/surface-secrets
|
||||
|
||||
# Offline Kit configuration
|
||||
SCANNER_OFFLINEKIT_ENABLED=false
|
||||
SCANNER_OFFLINEKIT_REQUIREDSSE=true
|
||||
SCANNER_OFFLINEKIT_REKOROFFLINEMODE=true
|
||||
SCANNER_OFFLINEKIT_TRUSTROOTDIRECTORY=/etc/stellaops/trust-roots
|
||||
SCANNER_OFFLINEKIT_REKORSNAPSHOTDIRECTORY=/var/lib/stellaops/rekor-snapshot
|
||||
SCANNER_OFFLINEKIT_TRUSTROOTS_HOST_PATH=./offline/trust-roots
|
||||
SCANNER_OFFLINEKIT_REKOR_SNAPSHOT_HOST_PATH=./offline/rekor-snapshot
|
||||
|
||||
# =============================================================================
|
||||
# SCHEDULER CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Queue configuration (Valkey only - NATS removed)
|
||||
SCHEDULER__QUEUE__KIND=Valkey
|
||||
SCHEDULER__QUEUE__VALKEY__URL=cache.stella-ops.local:6379
|
||||
SCHEDULER_SCANNER_BASEADDRESS=http://scanner.stella-ops.local
|
||||
|
||||
# =============================================================================
|
||||
# REKOR / SIGSTORE CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Rekor server URL (default: public Sigstore, use http://rekor-v2:3000 for local)
|
||||
REKOR_SERVER_URL=https://rekor.sigstore.dev
|
||||
REKOR_VERSION=V2
|
||||
REKOR_TILE_BASE_URL=
|
||||
REKOR_LOG_ID=c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d
|
||||
REKOR_TILES_IMAGE=ghcr.io/sigstore/rekor-tiles:latest
|
||||
|
||||
# =============================================================================
|
||||
# ADVISORY AI CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
ADVISORY_AI_WEB_PORT=8448
|
||||
ADVISORY_AI_SBOM_BASEADDRESS=http://scanner.stella-ops.local
|
||||
ADVISORY_AI_INFERENCE_MODE=Local
|
||||
ADVISORY_AI_REMOTE_BASEADDRESS=
|
||||
ADVISORY_AI_REMOTE_APIKEY=
|
||||
|
||||
# =============================================================================
|
||||
# CRYPTO CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Crypto profile: default, china, russia, eu
|
||||
STELLAOPS_CRYPTO_PROFILE=default
|
||||
|
||||
# Enable crypto simulation (for testing)
|
||||
STELLAOPS_CRYPTO_ENABLE_SIM=0
|
||||
STELLAOPS_CRYPTO_SIM_URL=http://sim-crypto:8080
|
||||
|
||||
# CryptoPro (Russia only) - requires EULA acceptance
|
||||
CRYPTOPRO_PORT=18080
|
||||
CRYPTOPRO_ACCEPT_EULA=0
|
||||
CRYPTOPRO_CONTAINER_NAME=stellaops-signing
|
||||
CRYPTOPRO_USE_MACHINE_STORE=true
|
||||
CRYPTOPRO_PROVIDER_TYPE=80
|
||||
|
||||
# SM Remote (China only)
|
||||
SM_REMOTE_PORT=56080
|
||||
SM_SOFT_ALLOWED=1
|
||||
SM_REMOTE_HSM_URL=
|
||||
SM_REMOTE_HSM_API_KEY=
|
||||
SM_REMOTE_HSM_TIMEOUT=30000
|
||||
|
||||
# =============================================================================
|
||||
# NETWORKING
|
||||
# =============================================================================
|
||||
|
||||
# External reverse proxy network (Traefik, Envoy, etc.)
|
||||
FRONTDOOR_NETWORK=stellaops_frontdoor
|
||||
|
||||
# =============================================================================
|
||||
# TELEMETRY (optional)
|
||||
# =============================================================================
|
||||
|
||||
OTEL_GRPC_PORT=4317
|
||||
OTEL_HTTP_PORT=4318
|
||||
OTEL_PROMETHEUS_PORT=9464
|
||||
PROMETHEUS_PORT=9090
|
||||
TEMPO_PORT=3200
|
||||
LOKI_PORT=3100
|
||||
PROMETHEUS_RETENTION=15d
|
||||
@@ -10,9 +10,11 @@
|
||||
# docker compose -f docker-compose.dev.yml up -d
|
||||
#
|
||||
# This provides:
|
||||
# - PostgreSQL 18.1 on port 5432
|
||||
# - Valkey 9.0.1 on port 6379
|
||||
# - RustFS on port 8080
|
||||
# - PostgreSQL 18.1 on 127.1.1.1:5432 (db.stella-ops.local)
|
||||
# - Valkey 9.0.1 on 127.1.1.2:6379 (cache.stella-ops.local)
|
||||
# - SeaweedFS (S3) on 127.1.1.3:8080 (s3.stella-ops.local)
|
||||
# - Rekor v2 (tiles) on 127.1.1.4:3322 (rekor.stella-ops.local)
|
||||
# - Zot (OCI registry) on 127.1.1.5:80 (registry.stella-ops.local)
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
@@ -27,7 +29,7 @@ services:
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
- "127.1.1.1:${POSTGRES_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-stellaops}"]
|
||||
interval: 10s
|
||||
@@ -42,7 +44,7 @@ services:
|
||||
volumes:
|
||||
- valkey-data:/data
|
||||
ports:
|
||||
- "${VALKEY_PORT:-6379}:6379"
|
||||
- "127.1.1.2:${VALKEY_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -50,24 +52,52 @@ services:
|
||||
retries: 5
|
||||
|
||||
rustfs:
|
||||
image: registry.stella-ops.org/stellaops/rustfs:2025.09.2
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: stellaops-dev-rustfs
|
||||
restart: unless-stopped
|
||||
command: ["serve", "--listen", "0.0.0.0:8080", "--root", "/data"]
|
||||
environment:
|
||||
RUSTFS__LOG__LEVEL: info
|
||||
RUSTFS__STORAGE__PATH: /data
|
||||
command: ["server", "-s3", "-s3.port=8080", "-dir=/data"]
|
||||
volumes:
|
||||
- rustfs-data:/data
|
||||
ports:
|
||||
- "${RUSTFS_PORT:-8080}:8080"
|
||||
- "127.1.1.3:${RUSTFS_PORT:-8080}:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/status"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
rekor-v2:
|
||||
image: ${REKOR_TILES_IMAGE:-ghcr.io/sigstore/rekor-tiles:latest}
|
||||
container_name: stellaops-dev-rekor
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- rekor-tiles-data:/var/lib/rekor-tiles
|
||||
ports:
|
||||
- "127.1.1.4:${REKOR_PORT:-3322}:3322"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3322/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
registry:
|
||||
image: ghcr.io/project-zot/zot-linux-amd64:v2.1.3
|
||||
container_name: stellaops-dev-registry
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- registry-data:/var/lib/registry
|
||||
- ./zot-config.json:/etc/zot/config.json:ro
|
||||
ports:
|
||||
- "127.1.1.5:80:5000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:5000/v2/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
valkey-data:
|
||||
rustfs-data:
|
||||
rekor-tiles-data:
|
||||
registry-data:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
14
devops/compose/postgres-init/01-create-schemas.sql
Normal file
14
devops/compose/postgres-init/01-create-schemas.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Pre-create schemas referenced by Stella Ops services.
|
||||
-- Runs once on first PostgreSQL container start via docker-entrypoint-initdb.d.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS scanner;
|
||||
CREATE SCHEMA IF NOT EXISTS vex;
|
||||
CREATE SCHEMA IF NOT EXISTS scheduler;
|
||||
CREATE SCHEMA IF NOT EXISTS policy;
|
||||
CREATE SCHEMA IF NOT EXISTS notify;
|
||||
CREATE SCHEMA IF NOT EXISTS notifier;
|
||||
CREATE SCHEMA IF NOT EXISTS evidence;
|
||||
CREATE SCHEMA IF NOT EXISTS findings;
|
||||
CREATE SCHEMA IF NOT EXISTS timeline;
|
||||
CREATE SCHEMA IF NOT EXISTS doctor;
|
||||
CREATE SCHEMA IF NOT EXISTS issuer_directory;
|
||||
16
devops/compose/zot-config.json
Normal file
16
devops/compose/zot-config.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"distSpecVersion": "1.1.0",
|
||||
"storage": {
|
||||
"rootDirectory": "/var/lib/registry",
|
||||
"gc": true,
|
||||
"gcDelay": "1h",
|
||||
"gcInterval": "24h"
|
||||
},
|
||||
"http": {
|
||||
"address": "0.0.0.0",
|
||||
"port": "5000"
|
||||
},
|
||||
"log": {
|
||||
"level": "info"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Multi-stage Angular console image with non-root runtime (DOCKER-44-001)
|
||||
ARG NODE_IMAGE=node:20-bullseye-slim
|
||||
ARG NODE_IMAGE=node:20-bookworm-slim
|
||||
ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.27-alpine
|
||||
ARG APP_DIR=src/Web/StellaOps.Web
|
||||
ARG DIST_DIR=dist
|
||||
ARG APP_PORT=8080
|
||||
|
||||
FROM ${NODE_IMAGE} AS build
|
||||
ARG APP_DIR
|
||||
ARG DIST_DIR
|
||||
ENV npm_config_fund=false npm_config_audit=false SOURCE_DATE_EPOCH=1704067200
|
||||
WORKDIR /app
|
||||
COPY ${APP_DIR}/package*.json ./
|
||||
RUN npm ci --prefer-offline --no-progress --cache .npm
|
||||
RUN npm install --no-progress
|
||||
COPY ${APP_DIR}/ ./
|
||||
RUN npm run build -- --configuration=production --output-path=${DIST_DIR}
|
||||
|
||||
FROM ${NGINX_IMAGE} AS runtime
|
||||
ARG APP_PORT
|
||||
ARG APP_PORT=8080
|
||||
ARG DIST_DIR=dist
|
||||
ENV APP_PORT=${APP_PORT}
|
||||
USER 101
|
||||
WORKDIR /
|
||||
COPY --from=build /app/${DIST_DIR}/ /usr/share/nginx/html/
|
||||
COPY ops/devops/docker/healthcheck-frontend.sh /usr/local/bin/healthcheck-frontend.sh
|
||||
COPY devops/docker/healthcheck-frontend.sh /usr/local/bin/healthcheck-frontend.sh
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf && \
|
||||
cat > /etc/nginx/conf.d/default.conf <<CONF
|
||||
server {
|
||||
@@ -29,7 +32,7 @@ server {
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
location / {
|
||||
try_files $$uri $$uri/ /index.html;
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
}
|
||||
CONF
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Hardened multi-stage template for StellaOps services
|
||||
# Parameters are build-time ARGs so this file can be re-used across services.
|
||||
|
||||
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim
|
||||
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim
|
||||
ARG SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0-noble
|
||||
ARG RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-noble
|
||||
ARG APP_PROJECT=src/Service/Service.csproj
|
||||
ARG CONFIGURATION=Release
|
||||
ARG PUBLISH_DIR=/app/publish
|
||||
@@ -14,17 +14,26 @@ ARG APP_GID=10001
|
||||
ARG APP_PORT=8080
|
||||
|
||||
FROM ${SDK_IMAGE} AS build
|
||||
ARG APP_PROJECT
|
||||
ARG CONFIGURATION
|
||||
ARG PUBLISH_DIR
|
||||
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||
DOTNET_NOLOGO=1 \
|
||||
SOURCE_DATE_EPOCH=1704067200
|
||||
WORKDIR /src
|
||||
# Expect restore sources to be available offline via /.nuget/
|
||||
COPY . .
|
||||
RUN dotnet restore ${APP_PROJECT} --packages /.nuget/packages && \
|
||||
RUN dotnet restore ${APP_PROJECT} && \
|
||||
dotnet publish ${APP_PROJECT} -c ${CONFIGURATION} -o ${PUBLISH_DIR} \
|
||||
/p:UseAppHost=true /p:PublishTrimmed=false
|
||||
|
||||
FROM ${RUNTIME_IMAGE} AS runtime
|
||||
ARG APP_USER=stella
|
||||
ARG APP_UID=10001
|
||||
ARG APP_GID=10001
|
||||
ARG APP_PORT=8080
|
||||
ARG APP_BINARY=StellaOps.Service
|
||||
ARG PUBLISH_DIR=/app/publish
|
||||
# Create non-root user/group with stable ids for auditability
|
||||
RUN groupadd -r -g ${APP_GID} ${APP_USER} && \
|
||||
useradd -r -u ${APP_UID} -g ${APP_GID} -d /var/lib/${APP_USER} ${APP_USER} && \
|
||||
@@ -34,23 +43,27 @@ RUN groupadd -r -g ${APP_GID} ${APP_USER} && \
|
||||
WORKDIR /app
|
||||
COPY --from=build --chown=${APP_UID}:${APP_GID} ${PUBLISH_DIR}/ ./
|
||||
# Ship healthcheck helper; callers may override with their own script
|
||||
COPY --chown=${APP_UID}:${APP_GID} ops/devops/docker/healthcheck.sh /usr/local/bin/healthcheck.sh
|
||||
COPY --chown=${APP_UID}:${APP_GID} devops/docker/healthcheck.sh /usr/local/bin/healthcheck.sh
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:${APP_PORT} \
|
||||
DOTNET_EnableDiagnostics=0 \
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \
|
||||
COMPlus_EnableDiagnostics=0 \
|
||||
APP_BINARY=${APP_BINARY}
|
||||
|
||||
# Harden filesystem; deploys should also set readOnlyRootFilesystem true
|
||||
# Keep the native AppHost binary (+x) and DLLs read-only (400)
|
||||
RUN chmod 500 /app && \
|
||||
chmod +x /usr/local/bin/healthcheck.sh && \
|
||||
find /app -maxdepth 1 -type f -name '*.dll' -exec chmod 400 {} \; && \
|
||||
find /app -maxdepth 1 -type f -name '*.json' -exec chmod 400 {} \; && \
|
||||
find /app -maxdepth 1 -type f -name '*.pdb' -exec chmod 400 {} \; && \
|
||||
find /app -maxdepth 1 -type d -exec chmod 500 {} \; && \
|
||||
chmod 500 /app/${APP_BINARY} 2>/dev/null || true
|
||||
|
||||
USER ${APP_UID}:${APP_GID}
|
||||
EXPOSE ${APP_PORT}
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD /usr/local/bin/healthcheck.sh
|
||||
|
||||
# Harden filesystem; deploys should also set readOnlyRootFilesystem true
|
||||
RUN chmod 500 /app && \
|
||||
find /app -maxdepth 1 -type f -exec chmod 400 {} \; && \
|
||||
find /app -maxdepth 1 -type d -exec chmod 500 {} \;
|
||||
|
||||
# Use shell form so APP_BINARY env can be expanded without duplicating the template per service
|
||||
ENTRYPOINT ["sh","-c","exec ./\"$APP_BINARY\""]
|
||||
|
||||
106
devops/docker/build-all.ps1
Normal file
106
devops/docker/build-all.ps1
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build hardened Docker images for all Stella Ops services using the shared template/matrix.
|
||||
.DESCRIPTION
|
||||
PowerShell port of build-all.sh. Reads services-matrix.env (pipe-delimited) and builds
|
||||
each service image using Dockerfile.hardened.template (or Dockerfile.console for Angular).
|
||||
.PARAMETER Registry
|
||||
Docker image registry prefix. Default: stellaops
|
||||
.PARAMETER TagSuffix
|
||||
Tag suffix for built images. Default: dev
|
||||
.PARAMETER SdkImage
|
||||
.NET SDK base image. Default: mcr.microsoft.com/dotnet/sdk:10.0-noble
|
||||
.PARAMETER RuntimeImage
|
||||
.NET runtime base image. Default: mcr.microsoft.com/dotnet/aspnet:10.0-noble
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Registry = $env:REGISTRY ?? 'stellaops',
|
||||
[string]$TagSuffix = $env:TAG_SUFFIX ?? 'dev',
|
||||
[string]$SdkImage = $env:SDK_IMAGE ?? 'mcr.microsoft.com/dotnet/sdk:10.0-noble',
|
||||
[string]$RuntimeImage = $env:RUNTIME_IMAGE ?? 'mcr.microsoft.com/dotnet/aspnet:10.0-noble'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$Root = git rev-parse --show-toplevel 2>$null
|
||||
if (-not $Root) {
|
||||
Write-Error 'Not inside a git repository.'
|
||||
exit 1
|
||||
}
|
||||
$Root = $Root.Trim()
|
||||
|
||||
$MatrixPath = Join-Path $Root 'devops/docker/services-matrix.env'
|
||||
if (-not (Test-Path $MatrixPath)) {
|
||||
Write-Error "Matrix file not found: $MatrixPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Building services from $MatrixPath -> ${Registry}/<service>:${TagSuffix}" -ForegroundColor Cyan
|
||||
|
||||
$succeeded = @()
|
||||
$failed = @()
|
||||
|
||||
foreach ($line in Get-Content $MatrixPath) {
|
||||
$line = $line.Trim()
|
||||
if (-not $line -or $line.StartsWith('#')) { continue }
|
||||
|
||||
$parts = $line -split '\|'
|
||||
if ($parts.Count -lt 5) { continue }
|
||||
|
||||
$service = $parts[0]
|
||||
$dockerfile = $parts[1]
|
||||
$project = $parts[2]
|
||||
$binary = $parts[3]
|
||||
$port = $parts[4]
|
||||
|
||||
$image = "${Registry}/${service}:${TagSuffix}"
|
||||
$dfPath = Join-Path $Root $dockerfile
|
||||
|
||||
if (-not (Test-Path $dfPath)) {
|
||||
Write-Warning "Skipping ${service}: dockerfile missing ($dfPath)"
|
||||
continue
|
||||
}
|
||||
|
||||
if ($dockerfile -like '*Dockerfile.console*') {
|
||||
Write-Host "[console] $service -> $image" -ForegroundColor Yellow
|
||||
docker build `
|
||||
-f $dfPath $Root `
|
||||
--build-arg "APP_DIR=$project" `
|
||||
--build-arg "APP_PORT=$port" `
|
||||
-t $image
|
||||
}
|
||||
else {
|
||||
Write-Host "[service] $service -> $image" -ForegroundColor Green
|
||||
docker build `
|
||||
-f $dfPath $Root `
|
||||
--build-arg "SDK_IMAGE=$SdkImage" `
|
||||
--build-arg "RUNTIME_IMAGE=$RuntimeImage" `
|
||||
--build-arg "APP_PROJECT=$project" `
|
||||
--build-arg "APP_BINARY=$binary" `
|
||||
--build-arg "APP_PORT=$port" `
|
||||
-t $image
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$succeeded += $service
|
||||
}
|
||||
else {
|
||||
$failed += $service
|
||||
Write-Host "FAILED: $service" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '=== BUILD RESULTS ===' -ForegroundColor Cyan
|
||||
Write-Host "Succeeded ($($succeeded.Count)): $($succeeded -join ', ')" -ForegroundColor Green
|
||||
Write-Host "Failed ($($failed.Count)): $($failed -join ', ')" -ForegroundColor $(if ($failed.Count -gt 0) { 'Red' } else { 'Green' })
|
||||
Write-Host ''
|
||||
|
||||
if ($failed.Count -gt 0) {
|
||||
Write-Error 'Some builds failed. Fix the issues and re-run.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host 'Build complete. Remember to enforce readOnlyRootFilesystem at deploy time and run sbom_attest.sh (DOCKER-44-002).' -ForegroundColor Cyan
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build hardened images for the core services using the shared template/matrix (DOCKER-44-001)
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
FAILED=()
|
||||
SUCCEEDED=()
|
||||
|
||||
ROOT=${ROOT:-"$(git rev-parse --show-toplevel)"}
|
||||
MATRIX=${MATRIX:-"${ROOT}/ops/devops/docker/services-matrix.env"}
|
||||
MATRIX=${MATRIX:-"${ROOT}/devops/docker/services-matrix.env"}
|
||||
REGISTRY=${REGISTRY:-"stellaops"}
|
||||
TAG_SUFFIX=${TAG_SUFFIX:-"dev"}
|
||||
SDK_IMAGE=${SDK_IMAGE:-"mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim"}
|
||||
RUNTIME_IMAGE=${RUNTIME_IMAGE:-"mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim"}
|
||||
SDK_IMAGE=${SDK_IMAGE:-"mcr.microsoft.com/dotnet/sdk:10.0-noble"}
|
||||
RUNTIME_IMAGE=${RUNTIME_IMAGE:-"mcr.microsoft.com/dotnet/aspnet:10.0-noble"}
|
||||
|
||||
if [[ ! -f "${MATRIX}" ]]; then
|
||||
echo "matrix file not found: ${MATRIX}" >&2
|
||||
@@ -45,6 +47,22 @@ while IFS='|' read -r service dockerfile project binary port; do
|
||||
-t "${image}"
|
||||
fi
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
SUCCEEDED+=("${service}")
|
||||
else
|
||||
FAILED+=("${service}")
|
||||
echo "FAILED: ${service}" >&2
|
||||
fi
|
||||
|
||||
done < "${MATRIX}"
|
||||
|
||||
echo "" >&2
|
||||
echo "=== BUILD RESULTS ===" >&2
|
||||
echo "Succeeded (${#SUCCEEDED[@]}): ${SUCCEEDED[*]:-none}" >&2
|
||||
echo "Failed (${#FAILED[@]}): ${FAILED[*]:-none}" >&2
|
||||
echo "" >&2
|
||||
if [[ ${#FAILED[@]} -gt 0 ]]; then
|
||||
echo "Some builds failed. Fix the issues and re-run." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Build complete. Remember to enforce readOnlyRootFilesystem at deploy time and run sbom_attest.sh (DOCKER-44-002)." >&2
|
||||
|
||||
@@ -1,12 +1,112 @@
|
||||
# service|dockerfile|project|binary|port
|
||||
# Paths are relative to repo root; dockerfile is usually the shared hardened template.
|
||||
api|ops/devops/docker/Dockerfile.hardened.template|src/VulnExplorer/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj|StellaOps.VulnExplorer.Api|8080
|
||||
orchestrator|ops/devops/docker/Dockerfile.hardened.template|src/Orchestrator/StellaOps.Orchestrator.WebService/StellaOps.Orchestrator.WebService.csproj|StellaOps.Orchestrator.WebService|8080
|
||||
task-runner|ops/devops/docker/Dockerfile.hardened.template|src/Orchestrator/StellaOps.Orchestrator.Worker/StellaOps.Orchestrator.Worker.csproj|StellaOps.Orchestrator.Worker|8081
|
||||
concelier|ops/devops/docker/Dockerfile.hardened.template|src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj|StellaOps.Concelier.WebService|8080
|
||||
excititor|ops/devops/docker/Dockerfile.hardened.template|src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj|StellaOps.Excititor.WebService|8080
|
||||
policy|ops/devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj|StellaOps.Policy.Gateway|8084
|
||||
notify|ops/devops/docker/Dockerfile.hardened.template|src/Notify/StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj|StellaOps.Notify.WebService|8080
|
||||
export|ops/devops/docker/Dockerfile.hardened.template|src/ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj|StellaOps.ExportCenter.WebService|8080
|
||||
advisoryai|ops/devops/docker/Dockerfile.hardened.template|src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj|StellaOps.AdvisoryAI.WebService|8080
|
||||
console|ops/devops/docker/Dockerfile.console|src/Web/StellaOps.Web|StellaOps.Web|8080
|
||||
# Ordered by port-registry slot number. All services use port 8080 internally
|
||||
# unless they have a legacy port assignment (authority=8440, signer=8441, etc.).
|
||||
#
|
||||
# ── Slot 0: Router Gateway ──────────────────────────────────────────────────────
|
||||
router-gateway|devops/docker/Dockerfile.hardened.template|src/Router/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj|StellaOps.Gateway.WebService|8080
|
||||
# ── Slot 1: Platform ────────────────────────────────────────────────────────────
|
||||
platform|devops/docker/Dockerfile.hardened.template|src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj|StellaOps.Platform.WebService|8080
|
||||
# ── Slot 2: Authority ───────────────────────────────────────────────────────────
|
||||
authority|devops/docker/Dockerfile.hardened.template|src/Authority/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj|StellaOps.Authority|8440
|
||||
# ── Slot 3: Gateway ─────────────────────────────────────────────────────────────
|
||||
gateway|devops/docker/Dockerfile.hardened.template|src/Gateway/StellaOps.Gateway.WebService/StellaOps.Gateway.WebService.csproj|StellaOps.Gateway.WebService|8080
|
||||
# ── Slot 4: Attestor ────────────────────────────────────────────────────────────
|
||||
attestor|devops/docker/Dockerfile.hardened.template|src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService/StellaOps.Attestor.WebService.csproj|StellaOps.Attestor.WebService|8442
|
||||
# ── Slot 5: Attestor TileProxy ──────────────────────────────────────────────────
|
||||
attestor-tileproxy|devops/docker/Dockerfile.hardened.template|src/Attestor/StellaOps.Attestor.TileProxy/StellaOps.Attestor.TileProxy.csproj|StellaOps.Attestor.TileProxy|8080
|
||||
# ── Slot 6: Evidence Locker ─────────────────────────────────────────────────────
|
||||
evidence-locker-web|devops/docker/Dockerfile.hardened.template|src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.WebService/StellaOps.EvidenceLocker.WebService.csproj|StellaOps.EvidenceLocker.WebService|8080
|
||||
evidence-locker-worker|devops/docker/Dockerfile.hardened.template|src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Worker/StellaOps.EvidenceLocker.Worker.csproj|StellaOps.EvidenceLocker.Worker|8080
|
||||
# ── Slot 8: Scanner ─────────────────────────────────────────────────────────────
|
||||
scanner-web|devops/docker/Dockerfile.hardened.template|src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj|StellaOps.Scanner.WebService|8444
|
||||
scanner-worker|devops/docker/Dockerfile.hardened.template|src/Scanner/StellaOps.Scanner.Worker/StellaOps.Scanner.Worker.csproj|StellaOps.Scanner.Worker|8080
|
||||
# ── Slot 9: Concelier ───────────────────────────────────────────────────────────
|
||||
concelier|devops/docker/Dockerfile.hardened.template|src/Concelier/StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj|StellaOps.Concelier.WebService|8080
|
||||
# ── Slot 10: Excititor ──────────────────────────────────────────────────────────
|
||||
excititor|devops/docker/Dockerfile.hardened.template|src/Excititor/StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj|StellaOps.Excititor.WebService|8080
|
||||
excititor-worker|devops/docker/Dockerfile.hardened.template|src/Excititor/StellaOps.Excititor.Worker/StellaOps.Excititor.Worker.csproj|StellaOps.Excititor.Worker|8080
|
||||
# ── Slot 11: VexHub ─────────────────────────────────────────────────────────────
|
||||
vexhub-web|devops/docker/Dockerfile.hardened.template|src/VexHub/StellaOps.VexHub.WebService/StellaOps.VexHub.WebService.csproj|StellaOps.VexHub.WebService|8080
|
||||
# ── Slot 12: VexLens ────────────────────────────────────────────────────────────
|
||||
vexlens-web|devops/docker/Dockerfile.hardened.template|src/VexLens/StellaOps.VexLens.WebService/StellaOps.VexLens.WebService.csproj|StellaOps.VexLens.WebService|8080
|
||||
# ── Slot 13: VulnExplorer (api) ─────────────────────────────────────────────────
|
||||
api|devops/docker/Dockerfile.hardened.template|src/VulnExplorer/StellaOps.VulnExplorer.Api/StellaOps.VulnExplorer.Api.csproj|StellaOps.VulnExplorer.Api|8080
|
||||
# ── Slot 14: Policy Engine ──────────────────────────────────────────────────────
|
||||
policy-engine|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj|StellaOps.Policy.Engine|8080
|
||||
# ── Slot 15: Policy Gateway ─────────────────────────────────────────────────────
|
||||
policy|devops/docker/Dockerfile.hardened.template|src/Policy/StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj|StellaOps.Policy.Gateway|8084
|
||||
# ── Slot 16: RiskEngine ─────────────────────────────────────────────────────────
|
||||
riskengine-web|devops/docker/Dockerfile.hardened.template|src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.WebService/StellaOps.RiskEngine.WebService.csproj|StellaOps.RiskEngine.WebService|8080
|
||||
riskengine-worker|devops/docker/Dockerfile.hardened.template|src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.Worker/StellaOps.RiskEngine.Worker.csproj|StellaOps.RiskEngine.Worker|8080
|
||||
# ── Slot 17: Orchestrator ───────────────────────────────────────────────────────
|
||||
orchestrator|devops/docker/Dockerfile.hardened.template|src/Orchestrator/StellaOps.Orchestrator/StellaOps.Orchestrator.WebService/StellaOps.Orchestrator.WebService.csproj|StellaOps.Orchestrator.WebService|8080
|
||||
orchestrator-worker|devops/docker/Dockerfile.hardened.template|src/Orchestrator/StellaOps.Orchestrator/StellaOps.Orchestrator.Worker/StellaOps.Orchestrator.Worker.csproj|StellaOps.Orchestrator.Worker|8080
|
||||
# ── Slot 18: TaskRunner ─────────────────────────────────────────────────────────
|
||||
taskrunner-web|devops/docker/Dockerfile.hardened.template|src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.csproj|StellaOps.TaskRunner.WebService|8080
|
||||
taskrunner-worker|devops/docker/Dockerfile.hardened.template|src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.csproj|StellaOps.TaskRunner.Worker|8080
|
||||
# ── Slot 19: Scheduler ──────────────────────────────────────────────────────────
|
||||
scheduler-web|devops/docker/Dockerfile.hardened.template|src/Scheduler/StellaOps.Scheduler.WebService/StellaOps.Scheduler.WebService.csproj|StellaOps.Scheduler.WebService|8080
|
||||
scheduler-worker|devops/docker/Dockerfile.hardened.template|src/Scheduler/StellaOps.Scheduler.Worker.Host/StellaOps.Scheduler.Worker.Host.csproj|StellaOps.Scheduler.Worker.Host|8080
|
||||
# ── Slot 20: Graph ──────────────────────────────────────────────────────────────
|
||||
graph-api|devops/docker/Dockerfile.hardened.template|src/Graph/StellaOps.Graph.Api/StellaOps.Graph.Api.csproj|StellaOps.Graph.Api|8080
|
||||
# ── Slot 21: Cartographer ───────────────────────────────────────────────────────
|
||||
cartographer|devops/docker/Dockerfile.hardened.template|src/Cartographer/StellaOps.Cartographer/StellaOps.Cartographer.csproj|StellaOps.Cartographer|8080
|
||||
# ── Slot 22: ReachGraph ─────────────────────────────────────────────────────────
|
||||
reachgraph-web|devops/docker/Dockerfile.hardened.template|src/ReachGraph/StellaOps.ReachGraph.WebService/StellaOps.ReachGraph.WebService.csproj|StellaOps.ReachGraph.WebService|8080
|
||||
# ── Slot 23: Timeline Indexer ───────────────────────────────────────────────────
|
||||
timeline-indexer-web|devops/docker/Dockerfile.hardened.template|src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.WebService/StellaOps.TimelineIndexer.WebService.csproj|StellaOps.TimelineIndexer.WebService|8080
|
||||
timeline-indexer-worker|devops/docker/Dockerfile.hardened.template|src/TimelineIndexer/StellaOps.TimelineIndexer/StellaOps.TimelineIndexer.Worker/StellaOps.TimelineIndexer.Worker.csproj|StellaOps.TimelineIndexer.Worker|8080
|
||||
# ── Slot 24: Timeline ───────────────────────────────────────────────────────────
|
||||
timeline-web|devops/docker/Dockerfile.hardened.template|src/Timeline/StellaOps.Timeline.WebService/StellaOps.Timeline.WebService.csproj|StellaOps.Timeline.WebService|8080
|
||||
# ── Slot 25: Findings Ledger ────────────────────────────────────────────────────
|
||||
findings-ledger-web|devops/docker/Dockerfile.hardened.template|src/Findings/StellaOps.Findings.Ledger.WebService/StellaOps.Findings.Ledger.WebService.csproj|StellaOps.Findings.Ledger.WebService|8080
|
||||
# ── Slot 26: Doctor ─────────────────────────────────────────────────────────────
|
||||
doctor-web|devops/docker/Dockerfile.hardened.template|src/Doctor/StellaOps.Doctor.WebService/StellaOps.Doctor.WebService.csproj|StellaOps.Doctor.WebService|8080
|
||||
doctor-scheduler|devops/docker/Dockerfile.hardened.template|src/Doctor/StellaOps.Doctor.Scheduler/StellaOps.Doctor.Scheduler.csproj|StellaOps.Doctor.Scheduler|8080
|
||||
# ── Slot 27: OpsMemory ──────────────────────────────────────────────────────────
|
||||
opsmemory-web|devops/docker/Dockerfile.hardened.template|src/OpsMemory/StellaOps.OpsMemory.WebService/StellaOps.OpsMemory.WebService.csproj|StellaOps.OpsMemory.WebService|8080
|
||||
# ── Slot 28: Notifier ───────────────────────────────────────────────────────────
|
||||
notifier-web|devops/docker/Dockerfile.hardened.template|src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/StellaOps.Notifier.WebService.csproj|StellaOps.Notifier.WebService|8080
|
||||
notifier-worker|devops/docker/Dockerfile.hardened.template|src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj|StellaOps.Notifier.Worker|8080
|
||||
# ── Slot 29: Notify ─────────────────────────────────────────────────────────────
|
||||
notify-web|devops/docker/Dockerfile.hardened.template|src/Notify/StellaOps.Notify.WebService/StellaOps.Notify.WebService.csproj|StellaOps.Notify.WebService|8080
|
||||
# ── Slot 30: Signer ─────────────────────────────────────────────────────────────
|
||||
signer|devops/docker/Dockerfile.hardened.template|src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/StellaOps.Signer.WebService.csproj|StellaOps.Signer.WebService|8441
|
||||
# ── Slot 31: SmRemote ───────────────────────────────────────────────────────────
|
||||
smremote|devops/docker/Dockerfile.hardened.template|src/SmRemote/StellaOps.SmRemote.Service/StellaOps.SmRemote.Service.csproj|StellaOps.SmRemote.Service|8080
|
||||
# ── Slot 32: AirGap Controller ──────────────────────────────────────────────────
|
||||
airgap-controller|devops/docker/Dockerfile.hardened.template|src/AirGap/StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj|StellaOps.AirGap.Controller|8080
|
||||
# ── Slot 33: AirGap Time ────────────────────────────────────────────────────────
|
||||
airgap-time|devops/docker/Dockerfile.hardened.template|src/AirGap/StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj|StellaOps.AirGap.Time|8080
|
||||
# ── Slot 34: PacksRegistry ──────────────────────────────────────────────────────
|
||||
packsregistry-web|devops/docker/Dockerfile.hardened.template|src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/StellaOps.PacksRegistry.WebService.csproj|StellaOps.PacksRegistry.WebService|8080
|
||||
packsregistry-worker|devops/docker/Dockerfile.hardened.template|src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Worker/StellaOps.PacksRegistry.Worker.csproj|StellaOps.PacksRegistry.Worker|8080
|
||||
# ── Slot 35: Registry Token ─────────────────────────────────────────────────────
|
||||
registry-token|devops/docker/Dockerfile.hardened.template|src/Registry/StellaOps.Registry.TokenService/StellaOps.Registry.TokenService.csproj|StellaOps.Registry.TokenService|8080
|
||||
# ── Slot 36: BinaryIndex ────────────────────────────────────────────────────────
|
||||
binaryindex-web|devops/docker/Dockerfile.hardened.template|src/BinaryIndex/StellaOps.BinaryIndex.WebService/StellaOps.BinaryIndex.WebService.csproj|StellaOps.BinaryIndex.WebService|8080
|
||||
# ── Slot 37: IssuerDirectory ────────────────────────────────────────────────────
|
||||
issuer-directory-web|devops/docker/Dockerfile.hardened.template|src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/StellaOps.IssuerDirectory.WebService.csproj|StellaOps.IssuerDirectory.WebService|8080
|
||||
# ── Slot 38: Symbols ────────────────────────────────────────────────────────────
|
||||
symbols|devops/docker/Dockerfile.hardened.template|src/Symbols/StellaOps.Symbols.Server/StellaOps.Symbols.Server.csproj|StellaOps.Symbols.Server|8080
|
||||
# ── Slot 39: SbomService ────────────────────────────────────────────────────────
|
||||
sbomservice|devops/docker/Dockerfile.hardened.template|src/SbomService/StellaOps.SbomService/StellaOps.SbomService.csproj|StellaOps.SbomService|8080
|
||||
# ── Slot 40: ExportCenter ───────────────────────────────────────────────────────
|
||||
export|devops/docker/Dockerfile.hardened.template|src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/StellaOps.ExportCenter.WebService.csproj|StellaOps.ExportCenter.WebService|8080
|
||||
export-worker|devops/docker/Dockerfile.hardened.template|src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker/StellaOps.ExportCenter.Worker.csproj|StellaOps.ExportCenter.Worker|8080
|
||||
# ── Slot 41: Replay ─────────────────────────────────────────────────────────────
|
||||
replay-web|devops/docker/Dockerfile.hardened.template|src/Replay/StellaOps.Replay.WebService/StellaOps.Replay.WebService.csproj|StellaOps.Replay.WebService|8080
|
||||
# ── Slot 42: Integrations ───────────────────────────────────────────────────────
|
||||
integrations-web|devops/docker/Dockerfile.hardened.template|src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj|StellaOps.Integrations.WebService|8080
|
||||
# ── Slot 43: Zastava ────────────────────────────────────────────────────────────
|
||||
zastava-webhook|devops/docker/Dockerfile.hardened.template|src/Zastava/StellaOps.Zastava.Webhook/StellaOps.Zastava.Webhook.csproj|StellaOps.Zastava.Webhook|8080
|
||||
# ── Slot 44: Signals ────────────────────────────────────────────────────────────
|
||||
signals|devops/docker/Dockerfile.hardened.template|src/Signals/StellaOps.Signals/StellaOps.Signals.csproj|StellaOps.Signals|8080
|
||||
# ── Slot 45: AdvisoryAI ─────────────────────────────────────────────────────────
|
||||
advisory-ai-web|devops/docker/Dockerfile.hardened.template|src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.csproj|StellaOps.AdvisoryAI.WebService|8080
|
||||
advisory-ai-worker|devops/docker/Dockerfile.hardened.template|src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.csproj|StellaOps.AdvisoryAI.Worker|8080
|
||||
# ── Slot 46: Unknowns ───────────────────────────────────────────────────────────
|
||||
unknowns-web|devops/docker/Dockerfile.hardened.template|src/Unknowns/StellaOps.Unknowns.WebService/StellaOps.Unknowns.WebService.csproj|StellaOps.Unknowns.WebService|8080
|
||||
# ── Console (Angular frontend) ──────────────────────────────────────────────────
|
||||
console|devops/docker/Dockerfile.console|src/Web/StellaOps.Web|StellaOps.Web|8080
|
||||
|
||||
@@ -57,6 +57,8 @@ StellaOps is a deterministic, offline-first SBOM + VEX platform built as a micro
|
||||
---
|
||||
## Prerequisites
|
||||
|
||||
> **Looking for a quick setup checklist?** See [`docs/dev/DEV_ENVIRONMENT_SETUP.md`](dev/DEV_ENVIRONMENT_SETUP.md) for a streamlined, copy-paste-friendly guide covering prerequisites, hosts file, infrastructure, builds, and Docker images.
|
||||
|
||||
### Required Software
|
||||
|
||||
1. **Docker Desktop** (Windows/Mac) or **Docker Engine + Docker Compose** (Linux)
|
||||
@@ -67,12 +69,17 @@ StellaOps is a deterministic, offline-first SBOM + VEX platform built as a micro
|
||||
- Download: https://dotnet.microsoft.com/download/dotnet/10.0
|
||||
- Verify: `dotnet --version` (should show 10.0.x)
|
||||
|
||||
3. **Visual Studio 2022** (v17.12+) or **Visual Studio Code**
|
||||
3. **Node.js** (for Angular frontend)
|
||||
- Version: ^20.19.0 || ^22.12.0 || ^24.0.0 (see `src/Web/StellaOps.Web/package.json` engines)
|
||||
- npm: >=10.2.0
|
||||
- Verify: `node --version` / `npm --version`
|
||||
|
||||
4. **Visual Studio 2022** (v17.12+) or **Visual Studio Code**
|
||||
- Workload: ASP.NET and web development
|
||||
- Workload: .NET desktop development
|
||||
- Extension (VS Code): C# Dev Kit
|
||||
|
||||
4. **Git**
|
||||
5. **Git**
|
||||
- Version: 2.30+ recommended
|
||||
|
||||
### Optional Tools
|
||||
@@ -104,8 +111,8 @@ cd git.stella-ops.org
|
||||
|
||||
```bash
|
||||
# Copy the development environment template
|
||||
cd deploy\compose
|
||||
copy env\dev.env.example .env
|
||||
cd devops\compose
|
||||
copy env\stellaops.env.example .env
|
||||
|
||||
# Edit .env with your preferred text editor
|
||||
notepad .env
|
||||
@@ -119,13 +126,13 @@ notepad .env
|
||||
### Step 3: Start the Full Platform
|
||||
|
||||
```bash
|
||||
# From deploy/compose directory
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
# From devops/compose directory
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
**This will start all infrastructure and services:**
|
||||
- PostgreSQL v16+ (port 5432) - Primary database for all services
|
||||
- Valkey 8.0 (port 6379) - Cache, DPoP nonces, event streams, rate limiting
|
||||
- PostgreSQL 18.1 (port 5432) - Primary database for all services
|
||||
- Valkey 9.0.1 (port 6379) - Cache, DPoP nonces, event streams, rate limiting
|
||||
- RustFS (port 8080) - S3-compatible object storage for artifacts/SBOMs
|
||||
- Authority (port 8440) - OAuth2/OIDC authentication
|
||||
- Signer (port 8441) - Cryptographic signing
|
||||
@@ -138,15 +145,15 @@ docker compose -f docker-compose.dev.yaml up -d
|
||||
|
||||
```bash
|
||||
# Check all services are up
|
||||
docker compose -f docker-compose.dev.yaml ps
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
|
||||
# Check logs for a specific service
|
||||
docker compose -f docker-compose.dev.yaml logs -f scanner-web
|
||||
docker compose -f docker-compose.dev.yml logs -f scanner-web
|
||||
|
||||
# Check infrastructure health
|
||||
docker compose -f docker-compose.dev.yaml logs postgres
|
||||
docker compose -f docker-compose.dev.yaml logs valkey
|
||||
docker compose -f docker-compose.dev.yaml logs rustfs
|
||||
docker compose -f docker-compose.dev.yml logs postgres
|
||||
docker compose -f docker-compose.dev.yml logs valkey
|
||||
docker compose -f docker-compose.dev.yml logs rustfs
|
||||
```
|
||||
|
||||
### Step 5: Access the Platform
|
||||
@@ -176,7 +183,7 @@ Related references:
|
||||
Service-specific debugging guidance lives with each module to avoid stale, copy-pasted configuration examples.
|
||||
|
||||
Generic workflow:
|
||||
1. Stop the service container in `deploy/compose` (for example: `docker compose -f docker-compose.dev.yaml stop <service>`).
|
||||
1. Stop the service container in `devops/compose` (for example: `docker compose -f docker-compose.dev.yml stop <service>`).
|
||||
2. Run the service locally under a debugger.
|
||||
3. Update dependent services to call `host.docker.internal:<port>` (or your host IP) and restart them.
|
||||
4. Use the module operations docs for required env vars, auth scopes, and health checks.
|
||||
@@ -315,11 +322,11 @@ STELLAOPS_SCANNER__QUEUE__BROKER=nats://localhost:4222
|
||||
|
||||
```bash
|
||||
# 1. Start full platform
|
||||
cd deploy\compose
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
cd devops\compose
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# 2. Stop the service you want to debug
|
||||
docker compose -f docker-compose.dev.yaml stop scanner-web
|
||||
docker compose -f docker-compose.dev.yml stop scanner-web
|
||||
|
||||
# 3. Open Visual Studio
|
||||
cd C:\dev\New folder\git.stella-ops.org
|
||||
@@ -331,7 +338,7 @@ start src\Scanner\StellaOps.Scanner.sln
|
||||
curl -X POST http://localhost:5210/api/scans -H "Content-Type: application/json" -d '{"imageRef":"alpine:latest"}'
|
||||
|
||||
# 6. When done, stop VS debugger and restart Docker container
|
||||
docker compose -f docker-compose.dev.yaml start scanner-web
|
||||
docker compose -f docker-compose.dev.yml start scanner-web
|
||||
```
|
||||
|
||||
### Workflow 2: Debug Multiple Services Together
|
||||
@@ -340,7 +347,7 @@ docker compose -f docker-compose.dev.yaml start scanner-web
|
||||
|
||||
```bash
|
||||
# 1. Stop both containers
|
||||
docker compose -f docker-compose.dev.yaml stop scanner-web scanner-worker
|
||||
docker compose -f docker-compose.dev.yml stop scanner-web scanner-worker
|
||||
|
||||
# 2. In Visual Studio, configure multiple startup projects:
|
||||
# - Right-click solution > Properties
|
||||
@@ -361,8 +368,8 @@ cd src\Concelier\StellaOps.Concelier.WebService
|
||||
dotnet build
|
||||
|
||||
# 2. Stop Docker Concelier
|
||||
cd ..\..\..\deploy\compose
|
||||
docker compose -f docker-compose.dev.yaml stop concelier
|
||||
cd ..\..\..\devops\compose
|
||||
docker compose -f docker-compose.dev.yml stop concelier
|
||||
|
||||
# 3. Run Concelier in Visual Studio (F5)
|
||||
|
||||
@@ -371,7 +378,7 @@ docker compose -f docker-compose.dev.yaml stop concelier
|
||||
CONCELIER_BASEURL=http://host.docker.internal:5000
|
||||
|
||||
# 5. Restart Scanner to pick up new config
|
||||
docker compose -f docker-compose.dev.yaml restart scanner-web
|
||||
docker compose -f docker-compose.dev.yml restart scanner-web
|
||||
```
|
||||
|
||||
### Workflow 4: Reset Database State
|
||||
@@ -380,17 +387,17 @@ docker compose -f docker-compose.dev.yaml restart scanner-web
|
||||
|
||||
```bash
|
||||
# 1. Stop all services
|
||||
docker compose -f docker-compose.dev.yaml down
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
|
||||
# 2. Remove database volumes
|
||||
docker volume rm compose_postgres-data
|
||||
docker volume rm compose_valkey-data
|
||||
|
||||
# 3. Restart platform (will recreate volumes and databases)
|
||||
docker compose -f docker-compose.dev.yaml up -d
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# 4. Wait for migrations to run
|
||||
docker compose -f docker-compose.dev.yaml logs -f postgres
|
||||
docker compose -f docker-compose.dev.yml logs -f postgres
|
||||
# Look for migration completion messages
|
||||
```
|
||||
|
||||
@@ -400,7 +407,7 @@ docker compose -f docker-compose.dev.yaml logs -f postgres
|
||||
|
||||
```bash
|
||||
# 1. Use the air-gap compose profile
|
||||
cd deploy\compose
|
||||
cd devops\compose
|
||||
docker compose -f docker-compose.airgap.yaml up -d
|
||||
|
||||
# 2. Verify no external network calls
|
||||
@@ -519,18 +526,18 @@ Note: StackExchange.Redis reports "redis server(s)" even when Valkey is the back
|
||||
|
||||
1. **Check Valkey is running:**
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml ps valkey
|
||||
docker compose -f docker-compose.dev.yml ps valkey
|
||||
# Should show: State = "Up"
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.dev.yaml logs valkey
|
||||
docker compose -f docker-compose.dev.yml logs valkey
|
||||
```
|
||||
|
||||
2. **Reset Valkey:**
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml stop valkey
|
||||
docker compose -f docker-compose.dev.yml stop valkey
|
||||
docker volume rm compose_valkey-data
|
||||
docker compose -f docker-compose.dev.yaml up -d valkey
|
||||
docker compose -f docker-compose.dev.yml up -d valkey
|
||||
```
|
||||
|
||||
#### 5. Service Cannot Reach host.docker.internal
|
||||
@@ -546,7 +553,7 @@ Should work automatically with Docker Desktop.
|
||||
|
||||
**Solution (Linux):**
|
||||
|
||||
Add to docker-compose.dev.yaml:
|
||||
Add to docker-compose.dev.yml:
|
||||
```yaml
|
||||
services:
|
||||
scanner-web:
|
||||
@@ -644,7 +651,7 @@ Permission denied writing to /data/db
|
||||
sudo chown -R $USER:$USER ./volumes
|
||||
|
||||
# Or run Docker as root (not recommended for production)
|
||||
sudo docker compose -f docker-compose.dev.yaml up -d
|
||||
sudo docker compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
@@ -699,19 +706,19 @@ cd devops\compose
|
||||
docker compose -f docker-compose.stella-ops.yml up -d
|
||||
|
||||
# Stop a specific service for debugging
|
||||
docker compose -f docker-compose.dev.yaml stop <service-name>
|
||||
docker compose -f docker-compose.dev.yml stop <service-name>
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.dev.yaml logs -f <service-name>
|
||||
docker compose -f docker-compose.dev.yml logs -f <service-name>
|
||||
|
||||
# Restart a service
|
||||
docker compose -f docker-compose.dev.yaml restart <service-name>
|
||||
docker compose -f docker-compose.dev.yml restart <service-name>
|
||||
|
||||
# Stop all services
|
||||
docker compose -f docker-compose.dev.yaml down
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
|
||||
# Stop all services and remove volumes (DESTRUCTIVE)
|
||||
docker compose -f docker-compose.dev.yaml down -v
|
||||
docker compose -f docker-compose.dev.yml down -v
|
||||
|
||||
# Build the module solution (see docs/dev/SOLUTION_BUILD_GUIDE.md)
|
||||
cd C:\dev\New folder\git.stella-ops.org
|
||||
|
||||
336
docs/dev/DEV_ENVIRONMENT_SETUP.md
Normal file
336
docs/dev/DEV_ENVIRONMENT_SETUP.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Dev Environment Setup
|
||||
|
||||
Actionable checklist for getting a local Stella Ops development environment running.
|
||||
For hybrid debugging workflows and service-specific guides, see [`docs/DEVELOPER_ONBOARDING.md`](../DEVELOPER_ONBOARDING.md).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (automated)
|
||||
|
||||
Setup scripts validate prerequisites, start infrastructure, build solutions and Docker images, and launch the full platform.
|
||||
|
||||
**Windows (PowerShell 7):**
|
||||
|
||||
```powershell
|
||||
.\scripts\setup.ps1 # full setup
|
||||
.\scripts\setup.ps1 -InfraOnly # infrastructure only (PostgreSQL, Valkey, SeaweedFS, Rekor, Zot)
|
||||
.\scripts\setup.ps1 -SkipBuild # skip .NET builds, build images and start platform
|
||||
.\scripts\setup.ps1 -SkipImages # build .NET but skip Docker images
|
||||
.\scripts\setup.ps1 -ImagesOnly # only build Docker images
|
||||
```
|
||||
|
||||
**Linux / macOS:**
|
||||
|
||||
```bash
|
||||
./scripts/setup.sh # full setup
|
||||
./scripts/setup.sh --infra-only # infrastructure only
|
||||
./scripts/setup.sh --skip-build # skip .NET builds
|
||||
./scripts/setup.sh --skip-images # skip Docker image builds
|
||||
./scripts/setup.sh --images-only # only build Docker images
|
||||
```
|
||||
|
||||
The scripts will check for required tools (dotnet 10.x, node 20+, npm 10+, docker, git), warn about missing hosts file entries, and copy `.env` from the example if needed. See the manual steps below for details on each stage.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
| Tool | Version | Verify |
|
||||
|------|---------|--------|
|
||||
| .NET 10 SDK | 10.0.100 (pinned in `global.json`) | `dotnet --version` |
|
||||
| Node.js | ^20.19.0 \|\| ^22.12.0 \|\| ^24.0.0 | `node --version` |
|
||||
| npm | >=10.2.0 | `npm --version` |
|
||||
| Docker Desktop / Engine + Compose | 20.10+ | `docker --version` |
|
||||
| Git | 2.30+ | `git --version` |
|
||||
| PowerShell 7+ (Windows) or Bash | -- | `pwsh --version` / `bash --version` |
|
||||
|
||||
### Optional
|
||||
|
||||
- Visual Studio 2022 v17.12+ (ASP.NET and web development workload)
|
||||
- VS Code + C# Dev Kit
|
||||
- PostgreSQL client (`psql`, DBeaver, pgAdmin)
|
||||
- `valkey-cli` or Redis Insight (Valkey is Redis-compatible)
|
||||
- AWS CLI or `s3cmd` for RustFS inspection
|
||||
|
||||
### System requirements
|
||||
|
||||
- **RAM:** 16 GB minimum, 32 GB recommended
|
||||
- **Disk:** 50 GB free (Docker images, volumes, build artifacts)
|
||||
- **CPU:** 4 cores minimum, 8 cores recommended
|
||||
|
||||
---
|
||||
|
||||
## 2. Hosts file setup
|
||||
|
||||
Each service binds to a unique loopback IP so all can use ports 443/80 without collisions.
|
||||
Full details: [`docs/technical/architecture/port-registry.md`](../technical/architecture/port-registry.md).
|
||||
|
||||
Add the block below to your hosts file:
|
||||
|
||||
- **Windows:** `C:\Windows\System32\drivers\etc\hosts` (run editor as Administrator)
|
||||
- **Linux / macOS:** `/etc/hosts` (use `sudo`)
|
||||
|
||||
```
|
||||
# Stella Ops local development hostnames
|
||||
# Each service gets a unique loopback IP so all can bind :443/:80 simultaneously.
|
||||
127.1.0.1 stella-ops.local
|
||||
127.1.0.2 router.stella-ops.local
|
||||
127.1.0.3 platform.stella-ops.local
|
||||
127.1.0.4 authority.stella-ops.local
|
||||
127.1.0.5 gateway.stella-ops.local
|
||||
127.1.0.6 attestor.stella-ops.local
|
||||
127.1.0.7 evidencelocker.stella-ops.local
|
||||
127.1.0.8 scanner.stella-ops.local
|
||||
127.1.0.9 concelier.stella-ops.local
|
||||
127.1.0.10 excititor.stella-ops.local
|
||||
127.1.0.11 vexhub.stella-ops.local
|
||||
127.1.0.12 vexlens.stella-ops.local
|
||||
127.1.0.13 vulnexplorer.stella-ops.local
|
||||
127.1.0.14 policy-engine.stella-ops.local
|
||||
127.1.0.15 policy-gateway.stella-ops.local
|
||||
127.1.0.16 riskengine.stella-ops.local
|
||||
127.1.0.17 orchestrator.stella-ops.local
|
||||
127.1.0.18 taskrunner.stella-ops.local
|
||||
127.1.0.19 scheduler.stella-ops.local
|
||||
127.1.0.20 graph.stella-ops.local
|
||||
127.1.0.21 cartographer.stella-ops.local
|
||||
127.1.0.22 reachgraph.stella-ops.local
|
||||
127.1.0.23 timelineindexer.stella-ops.local
|
||||
127.1.0.24 timeline.stella-ops.local
|
||||
127.1.0.25 findings.stella-ops.local
|
||||
127.1.0.26 doctor.stella-ops.local
|
||||
127.1.0.27 opsmemory.stella-ops.local
|
||||
127.1.0.28 notifier.stella-ops.local
|
||||
127.1.0.29 notify.stella-ops.local
|
||||
127.1.0.30 signer.stella-ops.local
|
||||
127.1.0.31 smremote.stella-ops.local
|
||||
127.1.0.32 airgap-controller.stella-ops.local
|
||||
127.1.0.33 airgap-time.stella-ops.local
|
||||
127.1.0.34 packsregistry.stella-ops.local
|
||||
127.1.0.35 registry-token.stella-ops.local
|
||||
127.1.0.36 binaryindex.stella-ops.local
|
||||
127.1.0.37 issuerdirectory.stella-ops.local
|
||||
127.1.0.38 symbols.stella-ops.local
|
||||
127.1.0.39 sbomservice.stella-ops.local
|
||||
127.1.0.40 exportcenter.stella-ops.local
|
||||
127.1.0.41 replay.stella-ops.local
|
||||
127.1.0.42 integrations.stella-ops.local
|
||||
127.1.0.43 signals.stella-ops.local
|
||||
127.1.0.44 advisoryai.stella-ops.local
|
||||
127.1.0.45 unknowns.stella-ops.local
|
||||
|
||||
# Stella Ops infrastructure (local dev containers)
|
||||
127.1.1.1 db.stella-ops.local
|
||||
127.1.1.2 cache.stella-ops.local
|
||||
127.1.1.3 s3.stella-ops.local
|
||||
127.1.1.4 rekor.stella-ops.local
|
||||
127.1.1.5 registry.stella-ops.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Start infrastructure (Docker)
|
||||
|
||||
```bash
|
||||
cd devops/compose
|
||||
cp env/stellaops.env.example .env # edit POSTGRES_PASSWORD at minimum
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
```
|
||||
|
||||
### Verify infrastructure
|
||||
|
||||
```bash
|
||||
# PostgreSQL
|
||||
psql -h db.stella-ops.local -U stellaops -d stellaops_dev -c "SELECT 1"
|
||||
|
||||
# Valkey
|
||||
valkey-cli -h cache.stella-ops.local ping
|
||||
```
|
||||
|
||||
Infrastructure versions (from `docker-compose.dev.yml`):
|
||||
|
||||
| Service | Version | Hostname | Port |
|
||||
|---------|---------|----------|------|
|
||||
| PostgreSQL | 18.1 | `db.stella-ops.local` | 5432 |
|
||||
| Valkey | 9.0.1 | `cache.stella-ops.local` | 6379 |
|
||||
| SeaweedFS (S3) | -- | `s3.stella-ops.local` | 8080 |
|
||||
| Rekor v2 | -- | `rekor.stella-ops.local` | 3322 |
|
||||
| Zot (OCI registry) | v2.1.3 | `registry.stella-ops.local` | 80 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Build .NET modules
|
||||
|
||||
The codebase uses a **module-first** approach -- there is no root solution file used for builds. Each module has its own `.sln` under `src/<Module>/`.
|
||||
|
||||
### Single module
|
||||
|
||||
```powershell
|
||||
dotnet build src\Scanner\StellaOps.Scanner.sln
|
||||
dotnet test src\Scanner\StellaOps.Scanner.sln
|
||||
```
|
||||
|
||||
### All modules
|
||||
|
||||
```powershell
|
||||
# Windows (PowerShell 7)
|
||||
.\scripts\build-all-solutions.ps1
|
||||
|
||||
# With tests
|
||||
.\scripts\build-all-solutions.ps1 -Test
|
||||
|
||||
# Linux / macOS
|
||||
./scripts/build-all-solutions.sh
|
||||
|
||||
# With tests
|
||||
./scripts/build-all-solutions.sh --test
|
||||
```
|
||||
|
||||
### Module solution index
|
||||
|
||||
See [`docs/dev/SOLUTION_BUILD_GUIDE.md`](SOLUTION_BUILD_GUIDE.md) for the authoritative list. Current modules (39):
|
||||
|
||||
| Module | Solution path |
|
||||
|--------|---------------|
|
||||
| AdvisoryAI | `src/AdvisoryAI/StellaOps.AdvisoryAI.sln` |
|
||||
| AirGap | `src/AirGap/StellaOps.AirGap.sln` |
|
||||
| Aoc | `src/Aoc/StellaOps.Aoc.sln` |
|
||||
| Attestor | `src/Attestor/StellaOps.Attestor.sln` |
|
||||
| Authority | `src/Authority/StellaOps.Authority.sln` |
|
||||
| Bench | `src/Bench/StellaOps.Bench.sln` |
|
||||
| BinaryIndex | `src/BinaryIndex/StellaOps.BinaryIndex.sln` |
|
||||
| Cartographer | `src/Cartographer/StellaOps.Cartographer.sln` |
|
||||
| Cli | `src/Cli/StellaOps.Cli.sln` |
|
||||
| Concelier | `src/Concelier/StellaOps.Concelier.sln` |
|
||||
| EvidenceLocker | `src/EvidenceLocker/StellaOps.EvidenceLocker.sln` |
|
||||
| Excititor | `src/Excititor/StellaOps.Excititor.sln` |
|
||||
| ExportCenter | `src/ExportCenter/StellaOps.ExportCenter.sln` |
|
||||
| Feedser | `src/Feedser/StellaOps.Feedser.sln` |
|
||||
| Findings | `src/Findings/StellaOps.Findings.sln` |
|
||||
| Gateway | `src/Gateway/StellaOps.Gateway.sln` |
|
||||
| Graph | `src/Graph/StellaOps.Graph.sln` |
|
||||
| IssuerDirectory | `src/IssuerDirectory/StellaOps.IssuerDirectory.sln` |
|
||||
| Notifier | `src/Notifier/StellaOps.Notifier.sln` |
|
||||
| Notify | `src/Notify/StellaOps.Notify.sln` |
|
||||
| Orchestrator | `src/Orchestrator/StellaOps.Orchestrator.sln` |
|
||||
| PacksRegistry | `src/PacksRegistry/StellaOps.PacksRegistry.sln` |
|
||||
| Policy | `src/Policy/StellaOps.Policy.sln` |
|
||||
| ReachGraph | `src/ReachGraph/StellaOps.ReachGraph.sln` |
|
||||
| Registry | `src/Registry/StellaOps.Registry.sln` |
|
||||
| Replay | `src/Replay/StellaOps.Replay.sln` |
|
||||
| RiskEngine | `src/RiskEngine/StellaOps.RiskEngine.sln` |
|
||||
| Router | `src/Router/StellaOps.Router.sln` |
|
||||
| SbomService | `src/SbomService/StellaOps.SbomService.sln` |
|
||||
| Scanner | `src/Scanner/StellaOps.Scanner.sln` |
|
||||
| Scheduler | `src/Scheduler/StellaOps.Scheduler.sln` |
|
||||
| Signer | `src/Signer/StellaOps.Signer.sln` |
|
||||
| Signals | `src/Signals/StellaOps.Signals.sln` |
|
||||
| SmRemote | `src/SmRemote/StellaOps.SmRemote.sln` |
|
||||
| TaskRunner | `src/TaskRunner/StellaOps.TaskRunner.sln` |
|
||||
| Telemetry | `src/Telemetry/StellaOps.Telemetry.sln` |
|
||||
| TimelineIndexer | `src/TimelineIndexer/StellaOps.TimelineIndexer.sln` |
|
||||
| Tools | `src/Tools/StellaOps.Tools.sln` |
|
||||
| VexHub | `src/VexHub/StellaOps.VexHub.sln` |
|
||||
| VexLens | `src/VexLens/StellaOps.VexLens.sln` |
|
||||
| VulnExplorer | `src/VulnExplorer/StellaOps.VulnExplorer.sln` |
|
||||
| Zastava | `src/Zastava/StellaOps.Zastava.sln` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Build Angular frontend
|
||||
|
||||
```bash
|
||||
cd src/Web/StellaOps.Web
|
||||
npm ci --prefer-offline --no-audit --no-fund
|
||||
npm run start # dev server -> https://stella-ops.local
|
||||
npm run build # production build
|
||||
npm run test # unit tests (Vitest)
|
||||
npm run test:e2e # Playwright E2E
|
||||
```
|
||||
|
||||
Additional scripts:
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npm run storybook` | Launch Storybook component explorer |
|
||||
| `npm run analyze` | Bundle size visualization (esbuild-visualizer) |
|
||||
| `npm run test:a11y` | Accessibility smoke tests |
|
||||
|
||||
---
|
||||
|
||||
## 6. Build Docker images
|
||||
|
||||
### Option A: Build all services (matrix-driven)
|
||||
|
||||
```bash
|
||||
cd devops/docker
|
||||
./build-all.sh
|
||||
```
|
||||
|
||||
Uses `services-matrix.env` and `Dockerfile.hardened.template` for .NET services, `Dockerfile.console` for Angular.
|
||||
|
||||
### Option B: Build a single .NET service
|
||||
|
||||
```bash
|
||||
docker build -f devops/docker/Dockerfile.hardened.template . \
|
||||
--build-arg SDK_IMAGE=mcr.microsoft.com/dotnet/sdk:10.0-bookworm-slim \
|
||||
--build-arg RUNTIME_IMAGE=mcr.microsoft.com/dotnet/aspnet:10.0-bookworm-slim \
|
||||
--build-arg APP_PROJECT=src/Scanner/StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj \
|
||||
--build-arg APP_BINARY=StellaOps.Scanner.WebService \
|
||||
--build-arg APP_PORT=8080 \
|
||||
-t stellaops/scanner-web:dev
|
||||
```
|
||||
|
||||
### Option C: Build the Angular console image
|
||||
|
||||
```bash
|
||||
docker build -f devops/docker/Dockerfile.console . \
|
||||
--build-arg APP_DIR=src/Web/StellaOps.Web \
|
||||
-t stellaops/console:dev
|
||||
```
|
||||
|
||||
### Release-quality builds (distroless)
|
||||
|
||||
Release Dockerfiles live under `devops/release/docker/`:
|
||||
|
||||
- `Dockerfile.dotnet-service` -- .NET services
|
||||
- `Dockerfile.angular-ui` -- Angular console
|
||||
|
||||
Component manifest: `devops/release/components.json`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Run the full platform
|
||||
|
||||
```bash
|
||||
# Core services
|
||||
docker compose -f devops/compose/docker-compose.stella-ops.yml up -d
|
||||
|
||||
# With Sigstore transparency log
|
||||
docker compose -f devops/compose/docker-compose.stella-ops.yml --profile sigstore up -d
|
||||
|
||||
# With telemetry stack
|
||||
docker compose -f devops/compose/docker-compose.stella-ops.yml \
|
||||
-f devops/compose/docker-compose.telemetry.yml up -d
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker compose -f devops/compose/docker-compose.stella-ops.yml ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Hybrid debugging (quick reference)
|
||||
|
||||
1. Start the full platform in Docker (section 7).
|
||||
2. Stop the container for the service you want to debug:
|
||||
```bash
|
||||
docker compose -f devops/compose/docker-compose.stella-ops.yml stop <service-name>
|
||||
```
|
||||
3. Run that service locally from your IDE (F5 in Visual Studio, or `dotnet run`).
|
||||
4. The local service uses `localhost` / `.stella-ops.local` hostnames to reach Docker-hosted infrastructure.
|
||||
|
||||
For detailed walkthroughs, configuration overrides, and multi-service debugging see [`docs/DEVELOPER_ONBOARDING.md`](../DEVELOPER_ONBOARDING.md).
|
||||
@@ -88,7 +88,7 @@ Completion criteria:
|
||||
- [ ] Applied changes logged with before/after counts.
|
||||
|
||||
### REMED-05 - Tier 2 manual remediation backlog
|
||||
Status: TODO
|
||||
Status: DOING
|
||||
Dependency: REMED-03
|
||||
Owners: Developer, QA
|
||||
Task description:
|
||||
@@ -198,6 +198,7 @@ Completion criteria:
|
||||
| 2026-01-31 | BLOCKED: Scheduler __Libraries missing `docs/modules/scheduler/implementation_plan.md`; SOLID review deferred. | Developer |
|
||||
| 2026-01-31 | BLOCKED: Policy __Libraries missing `docs/product/advisories/14-Dec-2025 - Smart-Diff Technical Reference.md`; SOLID review deferred. | Developer |
|
||||
| 2026-01-31 | BLOCKED: Signals __Libraries missing unknowns registry doc and archived sprint paths referenced by AGENTS; SOLID review deferred. | Developer |
|
||||
| 2026-02-04 | Aoc libraries remediated (private field naming, blocking async removed, IAocGuard split, AocWriteGuard and filter partials, service locator removal); Aoc tests passed (11 + 8). | Developer |
|
||||
| 2026-01-31 | BLOCKED: SbomService __Libraries missing required architecture/sprint docs; SOLID review deferred. | Developer |
|
||||
| 2026-01-31 | BLOCKED: Signer __Libraries required reading includes external Fulcio doc; blocked pending explicit user approval for web fetch. | Developer |
|
||||
| 2026-01-31 | BLOCKED: Zastava __Libraries missing `docs/modules/devops/runbooks/zastava-deployment.md`; SOLID review deferred. | Developer |
|
||||
@@ -298,6 +299,40 @@ Completion criteria:
|
||||
| 2026-02-03 | Remediated StellaOps.ReachGraph.Cache (ReachGraphValkeyCache split into <=100-line partials, ConfigureAwait(false) + cancellation checks, multi-endpoint invalidation); added ReachGraph.Cache unit tests for get/set/slice/invalidation/cancellation; `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Cache.Tests/StellaOps.ReachGraph.Cache.Tests.csproj` passed (9 tests). | Developer/QA |
|
||||
| 2026-02-03 | Remediated StellaOps.ReachGraph.Persistence (tenant filters added for list/get/delete, Intent traits added for tests); `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Persistence.Tests/StellaOps.ReachGraph.Persistence.Tests.csproj` passed (10 tests). | Developer/QA |
|
||||
| 2026-02-03 | Remediated StellaOps.ReachGraph core (dedup/hash/serialization/signing files split <=100 lines, ConfigureAwait(false) added in signing, new dedup/semantic key tests); `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/StellaOps.ReachGraph.Tests.csproj` passed (MTP0001 warning). | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Replay.Core.Tests (FeedSnapshot + Determinism validator tests split, ConfigureAwait(false) removed for xUnit); `dotnet test src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj` passed (64 tests) and `dotnet test src/__Libraries/__Tests/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj` passed (1 test). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Evidence.Bundle (evidence models split into single-purpose files, enum serialization test added); `dotnet test src/__Tests/StellaOps.Evidence.Bundle.Tests/StellaOps.Evidence.Bundle.Tests.csproj` passed (29 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Evidence.Core (adapters/store split into <=100-line partials, EvidenceBundleAdapter test added); `dotnet test src/__Libraries/StellaOps.Evidence.Core.Tests/StellaOps.Evidence.Core.Tests.csproj` passed (113 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Evidence (budget/retention/model/service/validation splits, ConfigureAwait(false) added, private field naming fixed; retention tier boundary test added); `dotnet test src/__Libraries/__Tests/StellaOps.Evidence.Tests/StellaOps.Evidence.Tests.csproj` passed (24 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | BLOCKED: StellaOps.Evidence.Pack remediation waiting on module AGENTS.md in src/__Libraries/StellaOps.Evidence.Pack. | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Interop.Tests (async naming, harness/model splits <= 100 lines, FindingsComparer tests added; ConfigureAwait(false) skipped in tests per xUnit1030); `dotnet test src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.csproj` passed (11 tests, 38 skipped). | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.IssuerDirectory.Client/Core.Tests (client partial split, options/models split, service locator removed, cache/tests split; ConfigureAwait(false) skipped in tests per xUnit1030); `dotnet test src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/StellaOps.IssuerDirectory.Core.Tests.csproj` passed (17 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.IssuerDirectory.Core (domain/service/validation partial splits, metrics field naming, domain/validator tests + missing issuer add test); `dotnet test src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/StellaOps.IssuerDirectory.Core.Tests.csproj` passed (23 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-05 | Remediated StellaOps.Replay (ReplayEngine split into partials/interfaces, loader digest guard + exceptions separated, failure timestamps use TimeProvider, loader tests added); `dotnet test src/__Libraries/__Tests/StellaOps.Replay.Tests/StellaOps.Replay.Tests.csproj` passed (11 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.IssuerDirectory.Persistence (repository partial splits, service locator removed, added unit/integration tests, IssuerAuditSinkTests split); `dotnet test src/IssuerDirectory/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/StellaOps.IssuerDirectory.Persistence.Tests.csproj` passed (15 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated AirGap.Bundle file-length outliers (SnapshotBundleReader.MerkleEntries and PolicySnapshotExtractor.Policy), split AirGap.Bundle test suite into <= 100-line partials with helpers; fixed missing usings; `dotnet test src/AirGap/__Libraries/__Tests/StellaOps.AirGap.Bundle.Tests/StellaOps.AirGap.Bundle.Tests.csproj` passed (150 tests). | Developer/QA |
|
||||
| 2026-02-05 | Remediated AirGap.Persistence (service locator removal, <=100-line splits, bundle version store coverage, unit DI registration tests, deterministic fixtures/Intent tags); `dotnet test src/AirGap/__Tests/StellaOps.AirGap.Persistence.Tests/StellaOps.AirGap.Persistence.Tests.csproj` passed (23 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated AirGap.Sync (service/transport/store splits <=100 lines, TimeProvider/path validation, metrics refactor, expanded unit coverage including FileBasedJobSyncTransport); `dotnet test src/AirGap/__Tests/StellaOps.AirGap.Sync.Tests/StellaOps.AirGap.Sync.Tests.csproj` passed (40 tests, MTP0001 warning). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated AirGap.Policy (EgressPolicy/EgressRule/EgressPolicyServiceCollectionExtensions splits <=100 lines, removed service locator registration, tests split and options binding verified); `dotnet test src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Tests/StellaOps.AirGap.Policy.Tests.csproj` passed (12 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated AirGap.Policy.Analyzers (HttpClientUsageAnalyzer split into diagnostics/analysis partials, private field naming fixed) and AirGap.Policy.Analyzers.Tests (tests split into partials with shared helpers, added HttpClientHandler construction + test-assembly name coverage; ConfigureAwait(false) omitted in test methods per xUnit1030); `dotnet test src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Analyzers.Tests/StellaOps.AirGap.Policy.Analyzers.Tests.csproj` passed (19 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated AirGap.Time (Rfc3161/Roughtime/TimeAnchorPolicyService splits, hosted startup validation replacing service locator, ConfigureAwait(false) applied, controller/health checks renamed Async) and AirGap.Time.Tests (test files split, ConfigureAwait(false) skipped per xUnit1030); `dotnet test src/AirGap/__Tests/StellaOps.AirGap.Time.Tests/StellaOps.AirGap.Time.Tests.csproj` passed (48 tests). SOLID review notes + status tables updated. | Developer/QA |
|
||||
| 2026-02-04 | Remediated BinaryIndex.Decompiler library/tests (interfaces/models split into <=100-line partials, parser/tokenizer refactor, keyword-only variable extraction filter, tests split + hex stack-offset coverage); `dotnet test src/BinaryIndex/__Tests/StellaOps.BinaryIndex.Decompiler.Tests/StellaOps.BinaryIndex.Decompiler.Tests.csproj` passed (35 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated BinaryIndex Disassembly.Abstractions + Disassembly.Tests (split >100-line files, removed service locator usage, renamed private fields); dotnet test rerun with `-p:BuildInParallel=false -p:UseSharedCompilation=false` after an MSBuild OOM on default run; 41 tests passed. | Developer |
|
||||
| 2026-02-04 | Remediated BinaryIndex Disassembly (split service/hybrid/DI files, extracted helpers, removed hybrid service locator) and added hybrid DI registration test; `dotnet test src/BinaryIndex/__Tests/StellaOps.BinaryIndex.Disassembly.Tests/StellaOps.BinaryIndex.Disassembly.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (42 tests). | Developer |
|
||||
| 2026-02-04 | Remediated BinaryIndex.Disassembly.B2R2 (plugin/pool/low-UIR split into <=100-line partials, private field rename, binary handle extracted); added B2R2 lifter pool + LowUIR support tests; `dotnet test src/BinaryIndex/__Tests/StellaOps.BinaryIndex.Disassembly.Tests/StellaOps.BinaryIndex.Disassembly.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (45 tests). | Developer |
|
||||
| 2026-02-04 | Remediated Authority.Timestamping.Abstractions (split request/response/token/verification/options into <=100-line files, fixed includeNonce null handling); added Timestamping.Abstractions unit test project; `dotnet test src/Authority/__Tests/StellaOps.Authority.Timestamping.Abstractions.Tests/StellaOps.Authority.Timestamping.Abstractions.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (16 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated Authority.Timestamping (split HttpTsaClient/registry/verifier/cache/ASN.1 files into <=100-line partials, ConfigureAwait(false) added in library awaits); added Timestamping unit test project; `dotnet test src/Authority/__Tests/StellaOps.Authority.Timestamping.Tests/StellaOps.Authority.Timestamping.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (10 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated Authority.Core verdicts (split manifest/builder/replay verifier/store into <=100-line files, fixed private field naming); expanded unit coverage for serializer empty JSON, replay diffs, and asset pagination; `dotnet test src/Authority/__Tests/StellaOps.Authority.Core.Tests/StellaOps.Authority.Core.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (46 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.AdvisoryAI.Attestation (service/registry/models/store split into <=100-line partials, IAiAttestationStore split, module AGENTS added); `dotnet test src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/StellaOps.AdvisoryAI.Attestation.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (58 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Cryptography.Plugin.EIDAS.Tests (tests split into partials, service locator removed, DI assertions updated, missing-key test added; ConfigureAwait(false) omitted due to xUnit1030); `dotnet test src/__Libraries/StellaOps.Cryptography.Plugin.EIDAS.Tests/StellaOps.Cryptography.Plugin.EIDAS.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (25 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Cryptography.Plugin.EIDAS (provider/options/client split into <=100-line partials, ConfigureAwait(false) added in library awaits); `dotnet test src/__Libraries/StellaOps.Cryptography.Plugin.EIDAS.Tests/StellaOps.Cryptography.Plugin.EIDAS.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (25 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Cryptography.DependencyInjection (removed service locator usage, split DI/validator/options files, added option configurators for SM/Sim HttpClients); added DI ordering + plugin-loading tests; `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (326 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.AuditPack (System-first using order in builder/importer/replay helpers; ArchiveUtilities extraction tests added); `dotnet test src/__Libraries/__Tests/StellaOps.AuditPack.Tests/StellaOps.AuditPack.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (52 tests). | Developer/QA |
|
||||
| 2026-02-04 | Remediated StellaOps.Auth.Security (DpopValidationOptions unit coverage added); `dotnet test src/__Libraries/__Tests/StellaOps.Auth.Security.Tests/StellaOps.Auth.Security.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (20 tests). | Developer/QA |
|
||||
| 2026-02-04 | BLOCKED: StellaOps.Cryptography.CertificateStatus.Abstractions and StellaOps.Cryptography.CertificateStatus missing module-local AGENTS.md; remediation deferred. | Developer |
|
||||
| 2026-02-04 | Remediated StellaOps.Cryptography.Plugin.BouncyCastle (private field naming fixed, provider split into <=100-line partials, key normalization tests added); `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (330 tests). | Developer/QA |
|
||||
| 2026-02-04 | Started StellaOps.Cryptography.Kms remediation review (AGENTS read; audit checklist loaded); work in progress. | Developer |
|
||||
| 2026-02-04 | Remediated StellaOps.Cryptography.Kms (async naming + file splits <= 100 lines, service locator removal, blocking async removal, public key handling updates); `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Kms.Tests/StellaOps.Cryptography.Kms.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (9 tests, MTP0001 warning) and `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (326 tests). | Developer/QA |
|
||||
| 2026-02-04 | BLOCKED: StellaOps.Cryptography.CertificateStatus.Abstractions and StellaOps.Cryptography.CertificateStatus missing module-local AGENTS.md; remediation deferred. | Developer |
|
||||
## Decisions & Risks
|
||||
- Decision: Remediation proceeds in tiers (safe automation, reviewed automation, manual fixes).
|
||||
- Decision: All automation must be deterministic, offline, and logged to `docs/implplan/audits/csproj-standards/remediation/`.
|
||||
@@ -310,7 +345,7 @@ Completion criteria:
|
||||
- Risk: Tier 1 symbol-aware changes require module expertise; schedule review windows per module.
|
||||
- Risk: File-by-file ramp increases timeline; adjust staffing to maintain momentum.
|
||||
- Risk: `src/__Libraries/__Tests/StellaOps.Orchestrator.Schemas.Tests` remediation blocked until module-local `AGENTS.md` exists (PM task required).
|
||||
- Risk: `src/__Libraries/StellaOps.AdvisoryAI.Attestation` remediation blocked until module-local `AGENTS.md` exists.
|
||||
- Resolved: Added module AGENTS for `src/__Libraries/StellaOps.AdvisoryAI.Attestation`; remediation unblocked.
|
||||
- Risk: Tier 0 left UsingInsideNamespace findings in 7 Scanner library files due to safe automation constraints; requires Tier 1/2 follow-up.
|
||||
- Risk: Tier 0 tool (`csproj-remediate-tier0.ps1`) has 3 known bugs discovered during repo-wide application: (1) **GlobalUsings.cs files are emptied** ? tool sorts `global using` directives but does not write them back, resulting in empty files. Workaround: revert GlobalUsings.cs. (2) **Top-level statement files break** ? `using var x = ...` disposal declarations are treated as using directives and moved into the sorted block. Workaround: revert affected Program.cs files. (3) **Duplicate usings not deduplicated** ? sorting can produce duplicate lines when usings appeared in multiple regions. Manual fix required. These bugs should be fixed before Tier 0 is used for future sprints.
|
||||
- Decision: Remaining 36 UsingNotSorted files are in GlobalUsings.cs or preprocessor-guarded files; these are Tier 1/2 scope and safe to defer.
|
||||
@@ -318,6 +353,7 @@ Completion criteria:
|
||||
- Resolved: Added module AGENTS for StellaOps.Artifact.Core.Tests; REMED-07 closed.
|
||||
- Decision: When file-audit.csv lacks entries for a project, generate SOLID notes by enumerating project .cs files (excluding bin/obj and auto-generated files).
|
||||
- Decision: Do not add ConfigureAwait(false) in xUnit tests when xUnit1030 flags it; treat ConfigureAwaitMissing as not applicable and record the exception in remediation notes.
|
||||
- Decision: ReplayEngine failure timestamps now use the injected TimeProvider; documented in `docs/modules/replay/guides/DETERMINISTIC_REPLAY.md`.
|
||||
- Decision: CSProj audit detail outputs are now canonical under `docs/implplan/audits/csproj-standards/src/**` after the IncludeTests rerun; legacy module-based folders are archival.
|
||||
- Decision: Per-project remediation checklists live under `docs/implplan/audits/csproj-standards/remediation/checklists/src/**` and serve as REMED-05/Tier 0-2 action sources.
|
||||
- Decision: Cross-module TASKS boards created in `src/**` to track remediation and SOLID status per project.
|
||||
@@ -330,6 +366,9 @@ Completion criteria:
|
||||
- Risk: Signer AGENTS reference external Fulcio documentation; SOLID review should be revalidated if external policy requirements change.
|
||||
- Risk: solid-review generator matches `<auto-generated` strings in source content; generator Program.cs required manual note. Consider tightening detection logic.
|
||||
- Decision: PolicyAuthoritySignals contract identifiers now enforce non-empty validation; remediation checklist updated in `docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.PolicyAuthoritySignals.Contracts/StellaOps.PolicyAuthoritySignals.Contracts.md`.
|
||||
- Decision: AOC guard library guidance updated to use `RequireAocGuard` with constructor-injected filter; see `docs/modules/aoc/guides/guard-library.md`.
|
||||
- Decision: Documented file-based job sync path root validation in `docs/modules/airgap/guides/job-sync-offline.md`.
|
||||
- Decision: DecompiledCodeParser.ExtractVariables now ignores keyword-only matches when the type token is not in the known type set to avoid false positives (e.g., return/goto); tests updated.
|
||||
## Next Checkpoints
|
||||
- Stage 0 (single-file) Tier 0 remediation validated.
|
||||
- Stage 1 (small batch) Tier 0 remediation validated.
|
||||
|
||||
@@ -68,8 +68,8 @@ Completion criteria:
|
||||
| Project: src/AirGap/__Tests/StellaOps.AirGap.Sync.Tests/StellaOps.AirGap.Sync.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/AirGap/__Tests/StellaOps.AirGap.Sync.Tests/StellaOps.AirGap.Sync.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/__Tests/StellaOps.AirGap.Sync.Tests/StellaOps.AirGap.Sync.Tests.md | TODO |
|
||||
| Project: src/AirGap/__Tests/StellaOps.AirGap.Time.Tests/StellaOps.AirGap.Time.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/AirGap/__Tests/StellaOps.AirGap.Time.Tests/StellaOps.AirGap.Time.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/__Tests/StellaOps.AirGap.Time.Tests/StellaOps.AirGap.Time.Tests.md | TODO |
|
||||
| Project: src/Aoc/__Analyzers/StellaOps.Aoc.Analyzers/StellaOps.Aoc.Analyzers.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Aoc/__Analyzers/StellaOps.Aoc.Analyzers/StellaOps.Aoc.Analyzers.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Aoc/__Analyzers/StellaOps.Aoc.Analyzers/StellaOps.Aoc.Analyzers.md | TODO |
|
||||
| Project: src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/StellaOps.Aoc.AspNetCore.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/StellaOps.Aoc.AspNetCore.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/StellaOps.Aoc.AspNetCore.md | TODO |
|
||||
| Project: src/Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.md | TODO |
|
||||
| Project: src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/StellaOps.Aoc.AspNetCore.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/StellaOps.Aoc.AspNetCore.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Aoc/__Libraries/StellaOps.Aoc.AspNetCore/StellaOps.Aoc.AspNetCore.md | DONE |
|
||||
| Project: src/Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.md | DONE |
|
||||
| Project: src/Aoc/__Tests/StellaOps.Aoc.Analyzers.Tests/StellaOps.Aoc.Analyzers.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Aoc/__Tests/StellaOps.Aoc.Analyzers.Tests/StellaOps.Aoc.Analyzers.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Aoc/__Tests/StellaOps.Aoc.Analyzers.Tests/StellaOps.Aoc.Analyzers.Tests.md | TODO |
|
||||
| Project: src/Aoc/__Tests/StellaOps.Aoc.AspNetCore.Tests/StellaOps.Aoc.AspNetCore.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Aoc/__Tests/StellaOps.Aoc.AspNetCore.Tests/StellaOps.Aoc.AspNetCore.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Aoc/__Tests/StellaOps.Aoc.AspNetCore.Tests/StellaOps.Aoc.AspNetCore.Tests.md | TODO |
|
||||
| Project: src/Aoc/__Tests/StellaOps.Aoc.Tests/StellaOps.Aoc.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Aoc/__Tests/StellaOps.Aoc.Tests/StellaOps.Aoc.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Aoc/__Tests/StellaOps.Aoc.Tests/StellaOps.Aoc.Tests.md | TODO |
|
||||
@@ -467,12 +467,12 @@ Completion criteria:
|
||||
| Project: src/Integrations/__Plugins/StellaOps.Integrations.Plugin.InMemory/StellaOps.Integrations.Plugin.InMemory.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.InMemory/StellaOps.Integrations.Plugin.InMemory.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.InMemory/StellaOps.Integrations.Plugin.InMemory.md | TODO |
|
||||
| Project: src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Integrations/__Tests/StellaOps.Integrations.Plugin.Tests/StellaOps.Integrations.Plugin.Tests.md | TODO |
|
||||
| Project: src/Integrations/__Tests/StellaOps.Integrations.Tests/StellaOps.Integrations.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Integrations/__Tests/StellaOps.Integrations.Tests/StellaOps.Integrations.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Integrations/__Tests/StellaOps.Integrations.Tests/StellaOps.Integrations.Tests.md | TODO |
|
||||
| Project: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/StellaOps.IssuerDirectory.Core.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/StellaOps.IssuerDirectory.Core.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/StellaOps.IssuerDirectory.Core.Tests.md | TODO |
|
||||
| Project: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core/StellaOps.IssuerDirectory.Core.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core/StellaOps.IssuerDirectory.Core.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core/StellaOps.IssuerDirectory.Core.md | TODO |
|
||||
| Project: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/StellaOps.IssuerDirectory.Core.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/StellaOps.IssuerDirectory.Core.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core.Tests/StellaOps.IssuerDirectory.Core.Tests.md | DONE |
|
||||
| Project: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core/StellaOps.IssuerDirectory.Core.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core/StellaOps.IssuerDirectory.Core.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Core/StellaOps.IssuerDirectory.Core.md | DONE |
|
||||
| Project: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure/StellaOps.IssuerDirectory.Infrastructure.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure/StellaOps.IssuerDirectory.Infrastructure.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.Infrastructure/StellaOps.IssuerDirectory.Infrastructure.md | TODO |
|
||||
| Project: src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/StellaOps.IssuerDirectory.WebService.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/StellaOps.IssuerDirectory.WebService.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService/StellaOps.IssuerDirectory.WebService.md | TODO |
|
||||
| Project: src/IssuerDirectory/__Libraries/StellaOps.IssuerDirectory.Persistence/StellaOps.IssuerDirectory.Persistence.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/__Libraries/StellaOps.IssuerDirectory.Persistence/StellaOps.IssuerDirectory.Persistence.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/__Libraries/StellaOps.IssuerDirectory.Persistence/StellaOps.IssuerDirectory.Persistence.md | TODO |
|
||||
| Project: src/IssuerDirectory/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/StellaOps.IssuerDirectory.Persistence.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/StellaOps.IssuerDirectory.Persistence.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/StellaOps.IssuerDirectory.Persistence.Tests.md | TODO |
|
||||
| Project: src/IssuerDirectory/__Libraries/StellaOps.IssuerDirectory.Persistence/StellaOps.IssuerDirectory.Persistence.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/__Libraries/StellaOps.IssuerDirectory.Persistence/StellaOps.IssuerDirectory.Persistence.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/__Libraries/StellaOps.IssuerDirectory.Persistence/StellaOps.IssuerDirectory.Persistence.md | DONE |
|
||||
| Project: src/IssuerDirectory/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/StellaOps.IssuerDirectory.Persistence.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/IssuerDirectory/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/StellaOps.IssuerDirectory.Persistence.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/IssuerDirectory/__Tests/StellaOps.IssuerDirectory.Persistence.Tests/StellaOps.IssuerDirectory.Persistence.Tests.md | DONE |
|
||||
| Project: src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/StellaOps.Notifier.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/StellaOps.Notifier.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Tests/StellaOps.Notifier.Tests.md | TODO |
|
||||
| Project: src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/StellaOps.Notifier.WebService.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/StellaOps.Notifier.WebService.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/StellaOps.Notifier.WebService.md | TODO |
|
||||
| Project: src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.md | TODO |
|
||||
@@ -985,12 +985,12 @@ Completion criteria:
|
||||
| Project: src/__Libraries/StellaOps.Doctor.Plugins.Verification/StellaOps.Doctor.Plugins.Verification.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Doctor.Plugins.Verification/StellaOps.Doctor.Plugins.Verification.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Doctor.Plugins.Verification/StellaOps.Doctor.Plugins.Verification.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Doctor/StellaOps.Doctor.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Doctor/StellaOps.Doctor.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Doctor/StellaOps.Doctor.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Eventing/StellaOps.Eventing.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Eventing/StellaOps.Eventing.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Eventing/StellaOps.Eventing.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Bundle/StellaOps.Evidence.Bundle.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Bundle/StellaOps.Evidence.Bundle.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Bundle/StellaOps.Evidence.Bundle.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Bundle/StellaOps.Evidence.Bundle.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Bundle/StellaOps.Evidence.Bundle.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Bundle/StellaOps.Evidence.Bundle.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Core.Tests/StellaOps.Evidence.Core.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Core.Tests/StellaOps.Evidence.Core.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Core.Tests/StellaOps.Evidence.Core.Tests.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Core/StellaOps.Evidence.Core.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Core/StellaOps.Evidence.Core.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Core/StellaOps.Evidence.Core.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Pack/StellaOps.Evidence.Pack.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Pack/StellaOps.Evidence.Pack.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Pack/StellaOps.Evidence.Pack.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Core/StellaOps.Evidence.Core.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Core/StellaOps.Evidence.Core.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Core/StellaOps.Evidence.Core.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Pack/StellaOps.Evidence.Pack.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Pack/StellaOps.Evidence.Pack.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Pack/StellaOps.Evidence.Pack.md | BLOCKED |
|
||||
| Project: src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence.Persistence/StellaOps.Evidence.Persistence.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Evidence/StellaOps.Evidence.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Facet.Tests/StellaOps.Facet.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Facet.Tests/StellaOps.Facet.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Facet.Tests/StellaOps.Facet.Tests.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Facet/StellaOps.Facet.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Facet/StellaOps.Facet.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Facet/StellaOps.Facet.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.FeatureFlags.Tests/StellaOps.FeatureFlags.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.FeatureFlags.Tests/StellaOps.FeatureFlags.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.FeatureFlags.Tests/StellaOps.FeatureFlags.Tests.md | TODO |
|
||||
@@ -1002,7 +1002,7 @@ Completion criteria:
|
||||
| Project: src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Ingestion.Telemetry/StellaOps.Ingestion.Telemetry.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Interop/StellaOps.Interop.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Interop/StellaOps.Interop.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Interop/StellaOps.Interop.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Metrics/StellaOps.Metrics.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Orchestrator.Schemas/StellaOps.Orchestrator.Schemas.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Orchestrator.Schemas/StellaOps.Orchestrator.Schemas.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Orchestrator.Schemas/StellaOps.Orchestrator.Schemas.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.md | TODO |
|
||||
@@ -1018,9 +1018,9 @@ Completion criteria:
|
||||
| Project: src/__Libraries/StellaOps.ReachGraph/StellaOps.ReachGraph.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.ReachGraph/StellaOps.ReachGraph.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.ReachGraph/StellaOps.ReachGraph.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Reachability.Core.Tests/StellaOps.Reachability.Core.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Reachability.Core.Tests/StellaOps.Reachability.Core.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Reachability.Core.Tests/StellaOps.Reachability.Core.Tests.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Reachability.Core/StellaOps.Reachability.Core.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Reachability.Core/StellaOps.Reachability.Core.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Reachability.Core/StellaOps.Reachability.Core.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Replay/StellaOps.Replay.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Replay/StellaOps.Replay.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Replay/StellaOps.Replay.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Replay/StellaOps.Replay.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Replay/StellaOps.Replay.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Replay/StellaOps.Replay.md | DONE |
|
||||
| Project: src/__Libraries/StellaOps.Resolver.Tests/StellaOps.Resolver.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Resolver.Tests/StellaOps.Resolver.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Resolver.Tests/StellaOps.Resolver.Tests.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Resolver/StellaOps.Resolver.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Resolver/StellaOps.Resolver.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Resolver/StellaOps.Resolver.md | TODO |
|
||||
| Project: src/__Libraries/StellaOps.Signals.Contracts/StellaOps.Signals.Contracts.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Libraries/StellaOps.Signals.Contracts/StellaOps.Signals.Contracts.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.Signals.Contracts/StellaOps.Signals.Contracts.md | TODO |
|
||||
@@ -1129,7 +1129,7 @@ Completion criteria:
|
||||
| Project: src/__Tests/e2e/Integrations/StellaOps.Integration.E2E.Integrations.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Tests/e2e/Integrations/StellaOps.Integration.E2E.Integrations.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/e2e/Integrations/StellaOps.Integration.E2E.Integrations.md | TODO |
|
||||
| Project: src/__Tests/e2e/ReplayableVerdict/StellaOps.E2E.ReplayableVerdict.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Tests/e2e/ReplayableVerdict/StellaOps.E2E.ReplayableVerdict.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/e2e/ReplayableVerdict/StellaOps.E2E.ReplayableVerdict.md | TODO |
|
||||
| Project: src/__Tests/e2e/RuntimeLinkage/StellaOps.E2E.RuntimeLinkage.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Tests/e2e/RuntimeLinkage/StellaOps.E2E.RuntimeLinkage.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/e2e/RuntimeLinkage/StellaOps.E2E.RuntimeLinkage.md | TODO |
|
||||
| Project: src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.md | TODO |
|
||||
| Project: src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/interop/StellaOps.Interop.Tests/StellaOps.Interop.Tests.md | DONE |
|
||||
| Project: src/__Tests/offline/StellaOps.Offline.E2E.Tests/StellaOps.Offline.E2E.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Tests/offline/StellaOps.Offline.E2E.Tests/StellaOps.Offline.E2E.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/offline/StellaOps.Offline.E2E.Tests/StellaOps.Offline.E2E.Tests.md | TODO |
|
||||
| Project: src/__Tests/parity/StellaOps.Parity.Tests/StellaOps.Parity.Tests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Tests/parity/StellaOps.Parity.Tests/StellaOps.Parity.Tests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/parity/StellaOps.Parity.Tests/StellaOps.Parity.Tests.md | TODO |
|
||||
| Project: src/__Tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.csproj; Analysis: DONE (audit + SOLID); Findings: docs/implplan/audits/csproj-standards/src/__Tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.md; Remediation: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Tests/reachability/StellaOps.Reachability.FixtureTests/StellaOps.Reachability.FixtureTests.md | TODO |
|
||||
@@ -1178,6 +1178,7 @@ Completion criteria:
|
||||
| 2026-02-03 | StellaOps.Cryptography.Plugin.OfflineVerification.Tests remediated; status set to DONE with test pass evidence. | Project Manager |
|
||||
| 2026-02-03 | StellaOps.Cryptography.Tests (__Libraries/__Tests) remediated; status set to DONE with test pass evidence. | Project Manager |
|
||||
| 2026-02-03 | StellaOps.DeltaVerdict.Tests (__Libraries/__Tests) remediated; status set to DONE with test pass evidence. | Project Manager |
|
||||
| 2026-02-04 | StellaOps.Aoc and StellaOps.Aoc.AspNetCore remediated; status set to DONE with test pass evidence (11 + 8 tests). | Project Manager |
|
||||
| 2026-02-03 | StellaOps.DistroIntel.Tests (__Libraries/__Tests) marked BLOCKED; missing module AGENTS.md. | Project Manager |
|
||||
| 2026-02-03 | StellaOps.AdvisoryAI.Attestation marked BLOCKED; missing module AGENTS.md in src/__Libraries/StellaOps.AdvisoryAI.Attestation. | Project Manager |
|
||||
| 2026-02-03 | StellaOps.Artifact.Core remediated; status set to DONE with test pass evidence (23 tests, MTP0001 warning). | Project Manager |
|
||||
@@ -1187,6 +1188,13 @@ Completion criteria:
|
||||
| 2026-02-03 | StellaOps.Configuration.SettingsStore marked BLOCKED; missing module AGENTS.md. | Project Manager |
|
||||
| 2026-02-03 | StellaOps.ReachGraph.Persistence remediated; status set to DONE with test pass evidence (10 tests). | Project Manager |
|
||||
| 2026-02-03 | StellaOps.ReachGraph remediated; status set to DONE with test pass evidence (MTP0001 warning). | Project Manager |
|
||||
| 2026-02-04 | StellaOps.Replay.Core + Replay.Core.Tests remediated; status set to DONE with test pass evidence (64 + 1 tests). | Project Manager |
|
||||
| 2026-02-04 | StellaOps.Evidence.Bundle remediated; status set to DONE with test pass evidence (29 tests). | Project Manager |
|
||||
| 2026-02-04 | StellaOps.Evidence.Core remediated; status set to DONE with test pass evidence (113 tests). | Project Manager |
|
||||
| 2026-02-04 | StellaOps.Evidence remediated; status set to DONE with test pass evidence (24 tests). | Project Manager |
|
||||
| 2026-02-04 | StellaOps.Evidence.Persistence remediated; status set to DONE with test pass evidence (35 tests). | Project Manager |
|
||||
| 2026-02-04 | StellaOps.Evidence.Pack marked BLOCKED; missing module AGENTS.md in src/__Libraries/StellaOps.Evidence.Pack. | Project Manager |
|
||||
| 2026-02-05 | StellaOps.Replay remediated; status set to DONE with test pass evidence (11 tests). | Project Manager |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: Status column reflects full remediation and test pass completion; DONE requires full remediation and tests.
|
||||
|
||||
@@ -180,6 +180,9 @@ services.AddRouterJobSyncTransport();
|
||||
services.AddAirGapSyncImportService();
|
||||
```
|
||||
|
||||
Notes:
|
||||
- File-based transport resolves bundle paths under the configured input/output roots and rejects traversal outside those roots.
|
||||
|
||||
## Operational Runbook
|
||||
|
||||
### Pre-Export Checklist
|
||||
|
||||
@@ -46,10 +46,10 @@ app.MapPost("/ingest", async (IngestionRequest request, IAocGuard guard, ILogger
|
||||
// additional application logic
|
||||
return Results.Accepted();
|
||||
})
|
||||
.AddEndpointFilter(new AocGuardEndpointFilter<IngestionRequest>(
|
||||
.RequireAocGuard<IngestionRequest>(
|
||||
request => new object?[] { request.Payload },
|
||||
serializerOptions: null,
|
||||
guardOptions: null))
|
||||
guardOptions: null)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithTags("AOC");
|
||||
|
||||
|
||||
@@ -155,7 +155,8 @@ Producer note: default clock values in `StellaOps.Replay.Core` are `UnixEpoch` t
|
||||
|
||||
### 4.1 Environment Normalization
|
||||
|
||||
* **Clock:** frozen to `scan.time` unless a rule explicitly requires “now”.
|
||||
* **Clock:** frozen to `scan.time` unless a rule explicitly requires “now”.
|
||||
* **Replay engine timestamps:** success and failure outputs must use the injected `TimeProvider` to keep replay timestamps deterministic.
|
||||
* **Random seed:** derived as `H(scan.id || MerkleRootAllLayers)`.
|
||||
* **Locale/TZ:** enforced per manifest; deviations cause validation error.
|
||||
* **Filesystem normalization:**
|
||||
|
||||
@@ -152,4 +152,23 @@ Add the following to your hosts file (`C:\Windows\System32\drivers\etc\hosts` on
|
||||
127.1.0.43 signals.stella-ops.local
|
||||
127.1.0.44 advisoryai.stella-ops.local
|
||||
127.1.0.45 unknowns.stella-ops.local
|
||||
|
||||
# Stella Ops infrastructure (local dev containers)
|
||||
127.1.1.1 db.stella-ops.local
|
||||
127.1.1.2 cache.stella-ops.local
|
||||
127.1.1.3 s3.stella-ops.local
|
||||
127.1.1.4 rekor.stella-ops.local
|
||||
127.1.1.5 registry.stella-ops.local
|
||||
```
|
||||
|
||||
### Infrastructure services
|
||||
|
||||
Infrastructure containers (databases, caches, object storage, transparency logs) use a separate loopback range (`127.1.1.x`) to avoid collisions with application services.
|
||||
|
||||
| IP | Hostname | Service | Port |
|
||||
|----|----------|---------|------|
|
||||
| `127.1.1.1` | `db.stella-ops.local` | PostgreSQL 18.1 | 5432 |
|
||||
| `127.1.1.2` | `cache.stella-ops.local` | Valkey 9.0.1 | 6379 |
|
||||
| `127.1.1.3` | `s3.stella-ops.local` | SeaweedFS (S3-compatible) | 8080 |
|
||||
| `127.1.1.4` | `rekor.stella-ops.local` | Rekor v2 (tiles) | 3322 |
|
||||
| `127.1.1.5` | `registry.stella-ops.local` | Zot (OCI registry) | 80 (→5000) |
|
||||
|
||||
5
etc/authority/keys/ack-token-dev.pem
Normal file
5
etc/authority/keys/ack-token-dev.pem
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMBVhnaKPP51XonFF
|
||||
s6a3c38QknKe7QE/2uG/Me87/1WhRANCAAT9pvHVdj0b4ipmeG5hO+6vIkKef3iz
|
||||
YCsDck4n0plEreGU6RQqjbNvonaz4RBfZgfRRijO9uwYd+6TRRba5Ud2
|
||||
-----END PRIVATE KEY-----
|
||||
11
etc/authority/keys/kestrel-dev.crt
Normal file
11
etc/authority/keys/kestrel-dev.crt
Normal file
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBjzCCATWgAwIBAgIUQo2xRh6gHa/pIP5/McOMYZGWzNkwCgYIKoZIzj0EAwIw
|
||||
HTEbMBkGA1UEAwwSKi5zdGVsbGEtb3BzLmxvY2FsMB4XDTI2MDIwNDAxMDgxMVoX
|
||||
DTI3MDIwNDAxMDgxMVowHTEbMBkGA1UEAwwSKi5zdGVsbGEtb3BzLmxvY2FsMFkw
|
||||
EwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEx9LZrM5VptvkH0OMO+08BVSDbJR7I4JQ
|
||||
UOkT9SWw41iQ9N44LTfgkAwFNOwDBjedyfSubChlVFeMzG5zTfeknaNTMFEwHQYD
|
||||
VR0OBBYEFK4aSrWW+fiMAh3KmDDqQAhUgQq9MB8GA1UdIwQYMBaAFK4aSrWW+fiM
|
||||
Ah3KmDDqQAhUgQq9MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIg
|
||||
e9gjH4e/4ZN8B0gAisYRnTOYjNFYhZ0i1r8hixgYvLgCIQDXSRkEkdPU2wh1CSDi
|
||||
i6AyDm/GS12iLQthGRITJdYejg==
|
||||
-----END CERTIFICATE-----
|
||||
5
etc/authority/keys/kestrel-dev.key
Normal file
5
etc/authority/keys/kestrel-dev.key
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgSrZgb+IjZY+zqdUF
|
||||
9R3gUmL0xtruaXM7vEfvlo/8JP6hRANCAATH0tmszlWm2+QfQ4w77TwFVINslHsj
|
||||
glBQ6RP1JbDjWJD03jgtN+CQDAU07AMGN53J9K5sKGVUV4zMbnNN96Sd
|
||||
-----END PRIVATE KEY-----
|
||||
BIN
etc/authority/keys/kestrel-dev.pfx
Normal file
BIN
etc/authority/keys/kestrel-dev.pfx
Normal file
Binary file not shown.
5
etc/authority/keys/signing-dev.pem
Normal file
5
etc/authority/keys/signing-dev.pem
Normal file
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9CXTA+ckwlbRXIPx
|
||||
jH2M2A8qIv0edRVA9zDM2GL1i7ahRANCAAQsUvdTeXbrxwoZ079ZY67F4292WsQ4
|
||||
/XDHJtursur+I0bTow9ARTiJXLDeWwRiaVo5uujewBLutxhK45xwYLFJ
|
||||
-----END PRIVATE KEY-----
|
||||
21
etc/issuer-directory/issuer-directory.yaml
Normal file
21
etc/issuer-directory/issuer-directory.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
IssuerDirectory:
|
||||
# Override connection secrets via environment variables (ISSUERDIRECTORY__MONGO__*)
|
||||
# rather than editing this file for production.
|
||||
telemetry:
|
||||
minimumLogLevel: Information
|
||||
authority:
|
||||
enabled: true
|
||||
issuer: http://authority.stella-ops.local
|
||||
requireHttpsMetadata: false
|
||||
audiences:
|
||||
- stellaops-platform
|
||||
readScope: issuer-directory:read
|
||||
writeScope: issuer-directory:write
|
||||
adminScope: issuer-directory:admin
|
||||
tenantHeader: X-StellaOps-Tenant
|
||||
seedCsafPublishers: true
|
||||
csafSeedPath: data/csaf-publishers.json
|
||||
Postgres:
|
||||
connectionString: "Host=db.stella-ops.local;Port=5432;Database=stellaops_platform;Username=stellaops;Password=stellaops"
|
||||
schema: issuer
|
||||
commandTimeoutSeconds: 30
|
||||
94
scripts/build-all-solutions.ps1
Normal file
94
scripts/build-all-solutions.ps1
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds (and optionally tests) all module solutions under src/.
|
||||
|
||||
.DESCRIPTION
|
||||
Discovers all *.sln files under src/ (excluding the root StellaOps.sln)
|
||||
and runs dotnet build on each. Pass -Test to also run dotnet test.
|
||||
|
||||
.PARAMETER Test
|
||||
Also run dotnet test on each solution after building.
|
||||
|
||||
.PARAMETER Configuration
|
||||
Build configuration. Defaults to Debug.
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\build-all-solutions.ps1
|
||||
.\scripts\build-all-solutions.ps1 -Test
|
||||
.\scripts\build-all-solutions.ps1 -Test -Configuration Release
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Test,
|
||||
[ValidateSet('Debug', 'Release')]
|
||||
[string]$Configuration = 'Debug'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$srcDir = Join-Path $repoRoot 'src'
|
||||
|
||||
$solutions = Get-ChildItem -Path $srcDir -Filter '*.sln' -Recurse |
|
||||
Where-Object { $_.Name -ne 'StellaOps.sln' } |
|
||||
Sort-Object FullName
|
||||
|
||||
if ($solutions.Count -eq 0) {
|
||||
Write-Error 'No solution files found under src/.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($solutions.Count) solution(s) to build." -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
|
||||
$buildPass = @()
|
||||
$buildFail = @()
|
||||
$testPass = @()
|
||||
$testFail = @()
|
||||
$testSkipped = @()
|
||||
|
||||
foreach ($sln in $solutions) {
|
||||
$rel = [System.IO.Path]::GetRelativePath($repoRoot, $sln.FullName)
|
||||
Write-Host "--- BUILD: $rel ---" -ForegroundColor Yellow
|
||||
|
||||
dotnet build $sln.FullName --configuration $Configuration --nologo -v quiet
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$buildPass += $rel
|
||||
} else {
|
||||
$buildFail += $rel
|
||||
Write-Host " FAILED" -ForegroundColor Red
|
||||
continue # skip test if build failed
|
||||
}
|
||||
|
||||
if ($Test) {
|
||||
Write-Host "--- TEST: $rel ---" -ForegroundColor Yellow
|
||||
dotnet test $sln.FullName --configuration $Configuration --nologo --no-build -v quiet
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$testPass += $rel
|
||||
} else {
|
||||
$testFail += $rel
|
||||
Write-Host " TEST FAILED" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '========== Summary ==========' -ForegroundColor Cyan
|
||||
Write-Host "Build passed : $($buildPass.Count)" -ForegroundColor Green
|
||||
if ($buildFail.Count -gt 0) {
|
||||
Write-Host "Build failed : $($buildFail.Count)" -ForegroundColor Red
|
||||
$buildFail | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
||||
}
|
||||
if ($Test) {
|
||||
Write-Host "Test passed : $($testPass.Count)" -ForegroundColor Green
|
||||
if ($testFail.Count -gt 0) {
|
||||
Write-Host "Test failed : $($testFail.Count)" -ForegroundColor Red
|
||||
$testFail | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
|
||||
}
|
||||
}
|
||||
|
||||
if ($buildFail.Count -gt 0 -or $testFail.Count -gt 0) {
|
||||
exit 1
|
||||
}
|
||||
82
scripts/build-all-solutions.sh
Normal file
82
scripts/build-all-solutions.sh
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build (and optionally test) all module solutions under src/.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-all-solutions.sh # build only
|
||||
# ./scripts/build-all-solutions.sh --test # build + test
|
||||
# ./scripts/build-all-solutions.sh --test --configuration Release
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SRC_DIR="$REPO_ROOT/src"
|
||||
|
||||
RUN_TESTS=false
|
||||
CONFIGURATION="Debug"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--test|-t) RUN_TESTS=true; shift ;;
|
||||
--configuration|-c) CONFIGURATION="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Discover solutions (exclude root StellaOps.sln)
|
||||
mapfile -t SOLUTIONS < <(find "$SRC_DIR" -name '*.sln' ! -name 'StellaOps.sln' | sort)
|
||||
|
||||
if [[ ${#SOLUTIONS[@]} -eq 0 ]]; then
|
||||
echo "ERROR: No solution files found under src/." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found ${#SOLUTIONS[@]} solution(s) to build."
|
||||
echo ""
|
||||
|
||||
build_pass=()
|
||||
build_fail=()
|
||||
test_pass=()
|
||||
test_fail=()
|
||||
|
||||
for sln in "${SOLUTIONS[@]}"; do
|
||||
rel="${sln#"$REPO_ROOT/"}"
|
||||
echo "--- BUILD: $rel ---"
|
||||
|
||||
if dotnet build "$sln" --configuration "$CONFIGURATION" --nologo -v quiet; then
|
||||
build_pass+=("$rel")
|
||||
else
|
||||
build_fail+=("$rel")
|
||||
echo " FAILED"
|
||||
continue
|
||||
fi
|
||||
|
||||
if $RUN_TESTS; then
|
||||
echo "--- TEST: $rel ---"
|
||||
if dotnet test "$sln" --configuration "$CONFIGURATION" --nologo --no-build -v quiet; then
|
||||
test_pass+=("$rel")
|
||||
else
|
||||
test_fail+=("$rel")
|
||||
echo " TEST FAILED"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "========== Summary =========="
|
||||
echo "Build passed : ${#build_pass[@]}"
|
||||
if [[ ${#build_fail[@]} -gt 0 ]]; then
|
||||
echo "Build failed : ${#build_fail[@]}"
|
||||
for f in "${build_fail[@]}"; do echo " - $f"; done
|
||||
fi
|
||||
if $RUN_TESTS; then
|
||||
echo "Test passed : ${#test_pass[@]}"
|
||||
if [[ ${#test_fail[@]} -gt 0 ]]; then
|
||||
echo "Test failed : ${#test_fail[@]}"
|
||||
for f in "${test_fail[@]}"; do echo " - $f"; done
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${#build_fail[@]} -gt 0 ]] || [[ ${#test_fail[@]} -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
337
scripts/setup.ps1
Normal file
337
scripts/setup.ps1
Normal file
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Automated developer environment setup for Stella Ops (Windows).
|
||||
.DESCRIPTION
|
||||
Validates prerequisites, starts infrastructure, builds solutions and Docker images,
|
||||
and launches the full platform.
|
||||
.PARAMETER SkipBuild
|
||||
Skip .NET solution builds.
|
||||
.PARAMETER InfraOnly
|
||||
Only start infrastructure containers (PostgreSQL, Valkey, SeaweedFS, Rekor, Zot).
|
||||
.PARAMETER ImagesOnly
|
||||
Only build Docker images (skip infra start and .NET build).
|
||||
.PARAMETER SkipImages
|
||||
Skip Docker image builds.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$SkipBuild,
|
||||
[switch]$InfraOnly,
|
||||
[switch]$ImagesOnly,
|
||||
[switch]$SkipImages
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$Root = git rev-parse --show-toplevel 2>$null
|
||||
if (-not $Root) {
|
||||
Write-Error 'Not inside a git repository. Run this script from within the Stella Ops repo.'
|
||||
exit 1
|
||||
}
|
||||
$Root = $Root.Trim()
|
||||
|
||||
$ComposeDir = Join-Path $Root 'devops/compose'
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Step([string]$msg) {
|
||||
Write-Host "`n>> $msg" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Write-Ok([string]$msg) {
|
||||
Write-Host " [OK] $msg" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Write-Warn([string]$msg) {
|
||||
Write-Host " [WARN] $msg" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
function Write-Fail([string]$msg) {
|
||||
Write-Host " [FAIL] $msg" -ForegroundColor Red
|
||||
}
|
||||
|
||||
function Test-Command([string]$cmd) {
|
||||
return [bool](Get-Command $cmd -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
# ─── 1. Check prerequisites ────────────────────────────────────────────────
|
||||
|
||||
function Test-Prerequisites {
|
||||
Write-Step 'Checking prerequisites'
|
||||
$allGood = $true
|
||||
|
||||
# dotnet
|
||||
if (Test-Command 'dotnet') {
|
||||
$v = (dotnet --version 2>$null)
|
||||
if ($v -match '^10\.') {
|
||||
Write-Ok "dotnet $v"
|
||||
} else {
|
||||
Write-Fail "dotnet $v found, but 10.x is required"
|
||||
$allGood = $false
|
||||
}
|
||||
} else {
|
||||
Write-Fail 'dotnet SDK not found. Install .NET 10 SDK.'
|
||||
$allGood = $false
|
||||
}
|
||||
|
||||
# node
|
||||
if (Test-Command 'node') {
|
||||
$v = (node --version 2>$null).TrimStart('v')
|
||||
$major = [int]($v -split '\.')[0]
|
||||
if ($major -ge 20) {
|
||||
Write-Ok "node $v"
|
||||
} else {
|
||||
Write-Fail "node $v found, but 20+ is required"
|
||||
$allGood = $false
|
||||
}
|
||||
} else {
|
||||
Write-Fail 'node not found. Install Node.js 20+.'
|
||||
$allGood = $false
|
||||
}
|
||||
|
||||
# npm
|
||||
if (Test-Command 'npm') {
|
||||
$v = (npm --version 2>$null)
|
||||
$major = [int]($v -split '\.')[0]
|
||||
if ($major -ge 10) {
|
||||
Write-Ok "npm $v"
|
||||
} else {
|
||||
Write-Fail "npm $v found, but 10+ is required"
|
||||
$allGood = $false
|
||||
}
|
||||
} else {
|
||||
Write-Fail 'npm not found.'
|
||||
$allGood = $false
|
||||
}
|
||||
|
||||
# docker
|
||||
if (Test-Command 'docker') {
|
||||
$v = (docker --version 2>$null)
|
||||
Write-Ok "docker: $v"
|
||||
} else {
|
||||
Write-Fail 'docker not found. Install Docker Desktop.'
|
||||
$allGood = $false
|
||||
}
|
||||
|
||||
# docker compose
|
||||
$composeOk = $false
|
||||
try {
|
||||
$null = docker compose version 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { $composeOk = $true }
|
||||
} catch {}
|
||||
if ($composeOk) {
|
||||
Write-Ok 'docker compose available'
|
||||
} else {
|
||||
Write-Fail 'docker compose not available. Ensure Docker Desktop includes Compose V2.'
|
||||
$allGood = $false
|
||||
}
|
||||
|
||||
# git
|
||||
if (Test-Command 'git') {
|
||||
Write-Ok "git $(git --version 2>$null)"
|
||||
} else {
|
||||
Write-Fail 'git not found.'
|
||||
$allGood = $false
|
||||
}
|
||||
|
||||
if (-not $allGood) {
|
||||
Write-Error 'Prerequisites not met. Install missing tools and re-run.'
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 2. Check hosts file ───────────────────────────────────────────────────
|
||||
|
||||
function Test-HostsFile {
|
||||
Write-Step 'Checking hosts file for stella-ops.local entries'
|
||||
$hostsPath = 'C:\Windows\System32\drivers\etc\hosts'
|
||||
if (Test-Path $hostsPath) {
|
||||
$content = Get-Content $hostsPath -Raw
|
||||
if ($content -match 'stella-ops\.local') {
|
||||
Write-Ok 'stella-ops.local entries found in hosts file'
|
||||
} else {
|
||||
Write-Warn 'stella-ops.local entries NOT found in hosts file.'
|
||||
Write-Host ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2' -ForegroundColor Yellow
|
||||
Write-Host ' to C:\Windows\System32\drivers\etc\hosts (run editor as Administrator)' -ForegroundColor Yellow
|
||||
}
|
||||
} else {
|
||||
Write-Warn "Cannot read hosts file at $hostsPath"
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 3. Ensure .env ────────────────────────────────────────────────────────
|
||||
|
||||
function Initialize-EnvFile {
|
||||
Write-Step 'Ensuring .env file exists'
|
||||
$envFile = Join-Path $ComposeDir '.env'
|
||||
$envExample = Join-Path $ComposeDir 'env/stellaops.env.example'
|
||||
|
||||
if (Test-Path $envFile) {
|
||||
Write-Ok ".env already exists at $envFile"
|
||||
} elseif (Test-Path $envExample) {
|
||||
Copy-Item $envExample $envFile
|
||||
Write-Ok "Copied $envExample -> $envFile"
|
||||
Write-Warn 'Review .env and change POSTGRES_PASSWORD at minimum.'
|
||||
} else {
|
||||
Write-Fail "Neither .env nor env/stellaops.env.example found in $ComposeDir"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 4. Start infrastructure ───────────────────────────────────────────────
|
||||
|
||||
function Start-Infrastructure {
|
||||
Write-Step 'Starting infrastructure containers (docker-compose.dev.yml)'
|
||||
Push-Location $ComposeDir
|
||||
try {
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail 'Failed to start infrastructure containers.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ' Waiting for containers to become healthy...' -ForegroundColor Gray
|
||||
$maxWait = 120
|
||||
$elapsed = 0
|
||||
while ($elapsed -lt $maxWait) {
|
||||
$ps = docker compose -f docker-compose.dev.yml ps --format json 2>$null
|
||||
if ($ps) {
|
||||
$allHealthy = $true
|
||||
# docker compose ps --format json outputs one JSON object per line
|
||||
foreach ($line in $ps -split "`n") {
|
||||
$line = $line.Trim()
|
||||
if (-not $line) { continue }
|
||||
try {
|
||||
$svc = $line | ConvertFrom-Json
|
||||
if ($svc.Health -and $svc.Health -ne 'healthy') {
|
||||
$allHealthy = $false
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if ($allHealthy -and $elapsed -gt 5) {
|
||||
Write-Ok 'All infrastructure containers healthy'
|
||||
return
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
$elapsed += 5
|
||||
}
|
||||
Write-Warn "Timed out waiting for healthy status after ${maxWait}s. Check with: docker compose -f docker-compose.dev.yml ps"
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 5. Build .NET solutions ───────────────────────────────────────────────
|
||||
|
||||
function Build-Solutions {
|
||||
Write-Step 'Building all .NET solutions'
|
||||
$buildScript = Join-Path $Root 'scripts/build-all-solutions.ps1'
|
||||
if (Test-Path $buildScript) {
|
||||
& $buildScript
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail '.NET solution build failed.'
|
||||
exit 1
|
||||
}
|
||||
Write-Ok '.NET solutions built successfully'
|
||||
} else {
|
||||
Write-Warn "Build script not found at $buildScript. Skipping .NET build."
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 6. Build Docker images ────────────────────────────────────────────────
|
||||
|
||||
function Build-Images {
|
||||
Write-Step 'Building Docker images'
|
||||
$buildScript = Join-Path $Root 'devops/docker/build-all.ps1'
|
||||
if (Test-Path $buildScript) {
|
||||
& $buildScript
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail 'Docker image build failed.'
|
||||
exit 1
|
||||
}
|
||||
Write-Ok 'Docker images built successfully'
|
||||
} else {
|
||||
Write-Warn "Build script not found at $buildScript. Skipping image build."
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 7. Start full platform ────────────────────────────────────────────────
|
||||
|
||||
function Start-Platform {
|
||||
Write-Step 'Starting full Stella Ops platform'
|
||||
Push-Location $ComposeDir
|
||||
try {
|
||||
docker compose -f docker-compose.stella-ops.yml up -d
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Fail 'Failed to start platform services.'
|
||||
exit 1
|
||||
}
|
||||
Write-Ok 'Platform services started'
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 8. Smoke test ─────────────────────────────────────────────────────────
|
||||
|
||||
function Test-Smoke {
|
||||
Write-Step 'Running smoke tests'
|
||||
$endpoints = @(
|
||||
@{ Name = 'PostgreSQL'; Cmd = { docker exec stellaops-dev-postgres pg_isready -U stellaops 2>$null; $LASTEXITCODE -eq 0 } },
|
||||
@{ Name = 'Valkey'; Cmd = { $r = docker exec stellaops-dev-valkey valkey-cli ping 2>$null; $r -eq 'PONG' } }
|
||||
)
|
||||
|
||||
foreach ($ep in $endpoints) {
|
||||
try {
|
||||
$ok = & $ep.Cmd
|
||||
if ($ok) { Write-Ok $ep.Name } else { Write-Warn "$($ep.Name) not responding" }
|
||||
} catch {
|
||||
Write-Warn "$($ep.Name) check failed: $_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host '=============================================' -ForegroundColor Cyan
|
||||
Write-Host ' Stella Ops Developer Environment Setup' -ForegroundColor Cyan
|
||||
Write-Host '=============================================' -ForegroundColor Cyan
|
||||
|
||||
Test-Prerequisites
|
||||
Test-HostsFile
|
||||
|
||||
if ($ImagesOnly) {
|
||||
Build-Images
|
||||
Write-Host "`nDone (images only)." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
Initialize-EnvFile
|
||||
Start-Infrastructure
|
||||
|
||||
if ($InfraOnly) {
|
||||
Test-Smoke
|
||||
Write-Host "`nDone (infra only). Infrastructure is running." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Build-Solutions
|
||||
}
|
||||
|
||||
if (-not $SkipImages) {
|
||||
Build-Images
|
||||
}
|
||||
|
||||
Start-Platform
|
||||
Test-Smoke
|
||||
|
||||
Write-Host "`n=============================================" -ForegroundColor Green
|
||||
Write-Host ' Setup complete!' -ForegroundColor Green
|
||||
Write-Host ' Platform: https://stella-ops.local' -ForegroundColor Green
|
||||
Write-Host ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md' -ForegroundColor Green
|
||||
Write-Host '=============================================' -ForegroundColor Green
|
||||
294
scripts/setup.sh
Normal file
294
scripts/setup.sh
Normal file
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env bash
|
||||
# Automated developer environment setup for Stella Ops (Linux/macOS).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup.sh [--skip-build] [--infra-only] [--images-only] [--skip-images]
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Parse flags ────────────────────────────────────────────────────────────
|
||||
|
||||
SKIP_BUILD=false
|
||||
INFRA_ONLY=false
|
||||
IMAGES_ONLY=false
|
||||
SKIP_IMAGES=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-build) SKIP_BUILD=true ;;
|
||||
--infra-only) INFRA_ONLY=true ;;
|
||||
--images-only) IMAGES_ONLY=true ;;
|
||||
--skip-images) SKIP_IMAGES=true ;;
|
||||
-h|--help)
|
||||
echo "Usage: $0 [--skip-build] [--infra-only] [--images-only] [--skip-images]"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown flag: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
if [[ -z "$ROOT" ]]; then
|
||||
echo "ERROR: Not inside a git repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COMPOSE_DIR="${ROOT}/devops/compose"
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
step() { printf '\n\033[1;36m>> %s\033[0m\n' "$1"; }
|
||||
ok() { printf ' \033[0;32m[OK]\033[0m %s\n' "$1"; }
|
||||
warn() { printf ' \033[0;33m[WARN]\033[0m %s\n' "$1"; }
|
||||
fail() { printf ' \033[0;31m[FAIL]\033[0m %s\n' "$1"; }
|
||||
|
||||
has_cmd() { command -v "$1" &>/dev/null; }
|
||||
|
||||
# ─── 1. Check prerequisites ────────────────────────────────────────────────
|
||||
|
||||
check_prerequisites() {
|
||||
step 'Checking prerequisites'
|
||||
local all_good=true
|
||||
|
||||
# dotnet
|
||||
if has_cmd dotnet; then
|
||||
local v; v=$(dotnet --version 2>/dev/null)
|
||||
if [[ "$v" =~ ^10\. ]]; then
|
||||
ok "dotnet $v"
|
||||
else
|
||||
fail "dotnet $v found, but 10.x is required"
|
||||
all_good=false
|
||||
fi
|
||||
else
|
||||
fail 'dotnet SDK not found. Install .NET 10 SDK.'
|
||||
all_good=false
|
||||
fi
|
||||
|
||||
# node
|
||||
if has_cmd node; then
|
||||
local v; v=$(node --version 2>/dev/null | sed 's/^v//')
|
||||
local major; major=$(echo "$v" | cut -d. -f1)
|
||||
if (( major >= 20 )); then
|
||||
ok "node $v"
|
||||
else
|
||||
fail "node $v found, but 20+ is required"
|
||||
all_good=false
|
||||
fi
|
||||
else
|
||||
fail 'node not found. Install Node.js 20+.'
|
||||
all_good=false
|
||||
fi
|
||||
|
||||
# npm
|
||||
if has_cmd npm; then
|
||||
local v; v=$(npm --version 2>/dev/null)
|
||||
local major; major=$(echo "$v" | cut -d. -f1)
|
||||
if (( major >= 10 )); then
|
||||
ok "npm $v"
|
||||
else
|
||||
fail "npm $v found, but 10+ is required"
|
||||
all_good=false
|
||||
fi
|
||||
else
|
||||
fail 'npm not found.'
|
||||
all_good=false
|
||||
fi
|
||||
|
||||
# docker
|
||||
if has_cmd docker; then
|
||||
ok "docker: $(docker --version 2>/dev/null)"
|
||||
else
|
||||
fail 'docker not found. Install Docker.'
|
||||
all_good=false
|
||||
fi
|
||||
|
||||
# docker compose
|
||||
if docker compose version &>/dev/null; then
|
||||
ok 'docker compose available'
|
||||
else
|
||||
fail 'docker compose not available. Install Compose V2.'
|
||||
all_good=false
|
||||
fi
|
||||
|
||||
# git
|
||||
if has_cmd git; then
|
||||
ok "$(git --version 2>/dev/null)"
|
||||
else
|
||||
fail 'git not found.'
|
||||
all_good=false
|
||||
fi
|
||||
|
||||
if [[ "$all_good" != "true" ]]; then
|
||||
echo 'ERROR: Prerequisites not met. Install missing tools and re-run.' >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 2. Check hosts file ───────────────────────────────────────────────────
|
||||
|
||||
check_hosts() {
|
||||
step 'Checking hosts file for stella-ops.local entries'
|
||||
if grep -q 'stella-ops\.local' /etc/hosts 2>/dev/null; then
|
||||
ok 'stella-ops.local entries found in /etc/hosts'
|
||||
else
|
||||
warn 'stella-ops.local entries NOT found in /etc/hosts.'
|
||||
echo ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2'
|
||||
echo ' to /etc/hosts (use sudo).'
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 3. Ensure .env ────────────────────────────────────────────────────────
|
||||
|
||||
ensure_env() {
|
||||
step 'Ensuring .env file exists'
|
||||
local env_file="${COMPOSE_DIR}/.env"
|
||||
local env_example="${COMPOSE_DIR}/env/stellaops.env.example"
|
||||
|
||||
if [[ -f "$env_file" ]]; then
|
||||
ok ".env already exists at $env_file"
|
||||
elif [[ -f "$env_example" ]]; then
|
||||
cp "$env_example" "$env_file"
|
||||
ok "Copied $env_example -> $env_file"
|
||||
warn 'Review .env and change POSTGRES_PASSWORD at minimum.'
|
||||
else
|
||||
fail "Neither .env nor env/stellaops.env.example found in $COMPOSE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 4. Start infrastructure ───────────────────────────────────────────────
|
||||
|
||||
start_infra() {
|
||||
step 'Starting infrastructure containers (docker-compose.dev.yml)'
|
||||
cd "$COMPOSE_DIR"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
echo ' Waiting for containers to become healthy...'
|
||||
local max_wait=120
|
||||
local elapsed=0
|
||||
while (( elapsed < max_wait )); do
|
||||
local all_healthy=true
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
local health; health=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Health',''))" 2>/dev/null || true)
|
||||
if [[ -n "$health" && "$health" != "healthy" ]]; then
|
||||
all_healthy=false
|
||||
fi
|
||||
done < <(docker compose -f docker-compose.dev.yml ps --format json 2>/dev/null)
|
||||
|
||||
if [[ "$all_healthy" == "true" && $elapsed -gt 5 ]]; then
|
||||
ok 'All infrastructure containers healthy'
|
||||
cd "$ROOT"
|
||||
return
|
||||
fi
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
warn "Timed out waiting for healthy status after ${max_wait}s."
|
||||
cd "$ROOT"
|
||||
}
|
||||
|
||||
# ─── 5. Build .NET solutions ───────────────────────────────────────────────
|
||||
|
||||
build_solutions() {
|
||||
step 'Building all .NET solutions'
|
||||
local script="${ROOT}/scripts/build-all-solutions.sh"
|
||||
if [[ -x "$script" ]]; then
|
||||
"$script"
|
||||
ok '.NET solutions built successfully'
|
||||
elif [[ -f "$script" ]]; then
|
||||
bash "$script"
|
||||
ok '.NET solutions built successfully'
|
||||
else
|
||||
warn "Build script not found at $script. Skipping .NET build."
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 6. Build Docker images ────────────────────────────────────────────────
|
||||
|
||||
build_images() {
|
||||
step 'Building Docker images'
|
||||
local script="${ROOT}/devops/docker/build-all.sh"
|
||||
if [[ -x "$script" ]]; then
|
||||
"$script"
|
||||
ok 'Docker images built successfully'
|
||||
elif [[ -f "$script" ]]; then
|
||||
bash "$script"
|
||||
ok 'Docker images built successfully'
|
||||
else
|
||||
warn "Build script not found at $script. Skipping image build."
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 7. Start full platform ────────────────────────────────────────────────
|
||||
|
||||
start_platform() {
|
||||
step 'Starting full Stella Ops platform'
|
||||
cd "$COMPOSE_DIR"
|
||||
docker compose -f docker-compose.stella-ops.yml up -d
|
||||
ok 'Platform services started'
|
||||
cd "$ROOT"
|
||||
}
|
||||
|
||||
# ─── 8. Smoke test ─────────────────────────────────────────────────────────
|
||||
|
||||
smoke_test() {
|
||||
step 'Running smoke tests'
|
||||
|
||||
if docker exec stellaops-dev-postgres pg_isready -U stellaops &>/dev/null; then
|
||||
ok 'PostgreSQL'
|
||||
else
|
||||
warn 'PostgreSQL not responding'
|
||||
fi
|
||||
|
||||
local pong; pong=$(docker exec stellaops-dev-valkey valkey-cli ping 2>/dev/null || true)
|
||||
if [[ "$pong" == "PONG" ]]; then
|
||||
ok 'Valkey'
|
||||
else
|
||||
warn 'Valkey not responding'
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo '============================================='
|
||||
echo ' Stella Ops Developer Environment Setup'
|
||||
echo '============================================='
|
||||
|
||||
check_prerequisites
|
||||
check_hosts
|
||||
|
||||
if [[ "$IMAGES_ONLY" == "true" ]]; then
|
||||
build_images
|
||||
echo ''
|
||||
echo 'Done (images only).'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ensure_env
|
||||
start_infra
|
||||
|
||||
if [[ "$INFRA_ONLY" == "true" ]]; then
|
||||
smoke_test
|
||||
echo ''
|
||||
echo 'Done (infra only). Infrastructure is running.'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_BUILD" != "true" ]]; then
|
||||
build_solutions
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_IMAGES" != "true" ]]; then
|
||||
build_images
|
||||
fi
|
||||
|
||||
start_platform
|
||||
smoke_test
|
||||
|
||||
echo ''
|
||||
echo '============================================='
|
||||
echo ' Setup complete!'
|
||||
echo ' Platform: https://stella-ops.local'
|
||||
echo ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md'
|
||||
echo '============================================='
|
||||
@@ -19,5 +19,6 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
<!-- Determinism abstractions -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,8 +4,9 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Worker.Services;
|
||||
using StellaOps.Worker.Health;
|
||||
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(args);
|
||||
var builder = WebApplication.CreateSlimBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
@@ -16,5 +17,8 @@ builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
builder.Services.AddSingleton<IAdvisoryJitterSource, DefaultAdvisoryJitterSource>();
|
||||
builder.Services.AddHostedService<AdvisoryTaskWorker>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
builder.Services.AddWorkerHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapWorkerHealthEndpoints();
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Worker.Health\StellaOps.Worker.Health.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
private static async Task<ImmutableArray<Diagnostic>> AnalyzeAsync(string source, string assemblyName)
|
||||
{
|
||||
var compilation = CSharpCompilation.Create(
|
||||
assemblyName,
|
||||
new[]
|
||||
{
|
||||
CSharpSyntaxTree.ParseText(source),
|
||||
CSharpSyntaxTree.ParseText(PolicyStubSource),
|
||||
},
|
||||
CreateMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer));
|
||||
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<MetadataReference> CreateMetadataReferences()
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Uri).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(HttpClient).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location);
|
||||
}
|
||||
|
||||
private const string PolicyStubSource = """
|
||||
namespace StellaOps.AirGap.Policy
|
||||
{
|
||||
public interface IEgressPolicy
|
||||
{
|
||||
void EnsureAllowed(EgressRequest request);
|
||||
}
|
||||
|
||||
public readonly record struct EgressRequest(string Component, System.Uri Destination, string Intent);
|
||||
|
||||
public static class EgressHttpClientFactory
|
||||
{
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request)
|
||||
=> throw new System.NotImplementedException();
|
||||
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request, System.Func<System.Net.Http.HttpClient> clientFactory)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -1,28 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CodeActions;
|
||||
using Microsoft.CodeAnalysis.CodeFixes;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed class HttpClientUsageAnalyzerTests
|
||||
public sealed partial class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReportsDiagnostic_ForNewHttpClient()
|
||||
[Fact]
|
||||
public async Task ReportsDiagnostic_ForNewHttpClientAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
@@ -43,8 +29,8 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_InsidePolicyAssembly()
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_InsidePolicyAssemblyAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
@@ -62,8 +48,11 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_ForTestingAssemblyNames()
|
||||
[Theory]
|
||||
[InlineData("Sample.App.Testing")]
|
||||
[InlineData("Sample.App.Test")]
|
||||
[InlineData("Sample.App.Tests")]
|
||||
public async Task DoesNotReportDiagnostic_ForTestAssemblyNamesAsync(string assemblyName)
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
@@ -79,53 +68,7 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Testing");
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName);
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
}
|
||||
|
||||
private static async Task<ImmutableArray<Diagnostic>> AnalyzeAsync(string source, string assemblyName)
|
||||
{
|
||||
var compilation = CSharpCompilation.Create(
|
||||
assemblyName,
|
||||
new[]
|
||||
{
|
||||
CSharpSyntaxTree.ParseText(source),
|
||||
CSharpSyntaxTree.ParseText(PolicyStubSource),
|
||||
},
|
||||
CreateMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer));
|
||||
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
|
||||
}
|
||||
|
||||
private static IEnumerable<MetadataReference> CreateMetadataReferences()
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Uri).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(HttpClient).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location);
|
||||
}
|
||||
|
||||
private const string PolicyStubSource = """
|
||||
namespace StellaOps.AirGap.Policy
|
||||
{
|
||||
public interface IEgressPolicy
|
||||
{
|
||||
void EnsureAllowed(EgressRequest request);
|
||||
}
|
||||
|
||||
public readonly record struct EgressRequest(string Component, System.Uri Destination, string Intent);
|
||||
|
||||
public static class EgressHttpClientFactory
|
||||
{
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request)
|
||||
=> throw new System.NotImplementedException();
|
||||
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request, System.Func<System.Net.Http.HttpClient> clientFactory)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForHttpClientParameterAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run(HttpClient client)
|
||||
{
|
||||
client.GetStringAsync("https://example.com");
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Using HttpClient as parameter should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForHttpClientFieldAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
private HttpClient? _client;
|
||||
|
||||
public void SetClient(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Declaring HttpClient field should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForFactoryMethodReturnAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public interface IHttpClientFactory
|
||||
{
|
||||
HttpClient CreateClient(string name);
|
||||
}
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
private readonly IHttpClientFactory _factory;
|
||||
|
||||
public Demo(IHttpClientFactory factory) => _factory = factory;
|
||||
|
||||
public void Run()
|
||||
{
|
||||
var client = _factory.CreateClient("default");
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Using factory method should not trigger diagnostic");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_InTestAssemblyAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App.Tests;
|
||||
|
||||
public sealed class DemoTests
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Tests");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Test assemblies should be exempt from diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_InPolicyAssemblyAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Internal;
|
||||
|
||||
internal static class Loopback
|
||||
{
|
||||
public static HttpClient Create() => new HttpClient();
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "StellaOps.AirGap.Policy");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Policy assembly itself should be exempt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PolicyAnalyzerRoslynTests - AN1 Roslyn compilation tests for AirGap.Policy.Analyzers
|
||||
// -----------------------------------------------------------------------------
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("var client = new HttpClient();", true, "Direct construction should trigger diagnostic")]
|
||||
[InlineData("var client = new System.Net.Http.HttpClient();", true, "Fully qualified construction should trigger diagnostic")]
|
||||
[InlineData("HttpClient client = new();", true, "Target-typed new should trigger diagnostic")]
|
||||
[InlineData("object client = new HttpClient();", true, "Implicit cast construction should trigger diagnostic")]
|
||||
[InlineData("var client = new HttpClient(new HttpClientHandler());", true, "Handler construction should trigger diagnostic")]
|
||||
public async Task DiagnosticTriggered_ForVariousHttpClientConstructionsAsync(string statement, bool shouldTrigger, string reason)
|
||||
{
|
||||
var source = $$"""
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
{{statement}}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var hasDiagnostic = diagnostics.Any(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
hasDiagnostic.Should().Be(shouldTrigger, reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Diagnostic_HasCorrectSeverityAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
airgapDiagnostic.Severity.Should().Be(DiagnosticSeverity.Warning,
|
||||
"Diagnostic should be a warning, not an error");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Diagnostic_HasCorrectLocationAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
airgapDiagnostic.Location.IsInSource.Should().BeTrue();
|
||||
var lineSpan = airgapDiagnostic.Location.GetLineSpan();
|
||||
lineSpan.StartLinePosition.Line.Should().Be(8, "Diagnostic should point to line 9 (0-indexed: 8)");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MultipleHttpClientUsages_ReportMultipleDiagnosticsAsync()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Method1()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
|
||||
public void Method2()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
|
||||
public void Method3()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var airgapDiagnostics = diagnostics.Where(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId).ToList();
|
||||
|
||||
airgapDiagnostics.Should().HaveCount(3, "Each new HttpClient() should trigger a separate diagnostic");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Analyzer_SupportedDiagnostics_ContainsExpectedId()
|
||||
{
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var supportedDiagnostics = analyzer.SupportedDiagnostics;
|
||||
|
||||
supportedDiagnostics.Should().HaveCount(1);
|
||||
supportedDiagnostics[0].Id.Should().Be("AIRGAP001");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
public sealed partial class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
private static async Task<ImmutableArray<Diagnostic>> AnalyzeAsync(string source, string assemblyName)
|
||||
{
|
||||
var compilation = CSharpCompilation.Create(
|
||||
assemblyName,
|
||||
new[]
|
||||
{
|
||||
CSharpSyntaxTree.ParseText(source),
|
||||
CSharpSyntaxTree.ParseText(PolicyStubSource),
|
||||
},
|
||||
CreateMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer));
|
||||
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<MetadataReference> CreateMetadataReferences()
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Uri).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(HttpClient).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location);
|
||||
|
||||
var systemRuntimePath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
if (!string.IsNullOrEmpty(systemRuntimePath))
|
||||
{
|
||||
var netstandard = Path.Combine(systemRuntimePath, "netstandard.dll");
|
||||
if (File.Exists(netstandard))
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(netstandard);
|
||||
}
|
||||
|
||||
var systemRuntime = Path.Combine(systemRuntimePath, "System.Runtime.dll");
|
||||
if (File.Exists(systemRuntime))
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(systemRuntime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const string PolicyStubSource = """
|
||||
namespace StellaOps.AirGap.Policy
|
||||
{
|
||||
public interface IEgressPolicy
|
||||
{
|
||||
void EnsureAllowed(EgressRequest request);
|
||||
}
|
||||
|
||||
public readonly record struct EgressRequest(string Component, System.Uri Destination, string Intent);
|
||||
|
||||
public static class EgressHttpClientFactory
|
||||
{
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PolicyAnalyzerRoslynTests.cs
|
||||
// Sprint: SPRINT_5100_0010_0004_airgap_tests
|
||||
// Tasks: AIRGAP-5100-005, AIRGAP-5100-006
|
||||
// Description: AN1 Roslyn compilation tests for AirGap.Policy.Analyzers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CodeActions;
|
||||
using Microsoft.CodeAnalysis.CodeFixes;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using Xunit;
|
||||
using FluentAssertions;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Policy.Analyzers.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// AN1 Roslyn Compilation Tests for AirGap.Policy.Analyzers
|
||||
/// Task AIRGAP-5100-005: Expected diagnostics, no false positives
|
||||
/// Task AIRGAP-5100-006: Golden generated code tests for policy analyzers
|
||||
/// </summary>
|
||||
public sealed class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
#region AIRGAP-5100-005: Expected Diagnostics & No False Positives
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("var client = new HttpClient();", true, "Direct construction should trigger diagnostic")]
|
||||
[InlineData("var client = new System.Net.Http.HttpClient();", true, "Fully qualified construction should trigger diagnostic")]
|
||||
[InlineData("HttpClient client = new();", true, "Target-typed new should trigger diagnostic")]
|
||||
[InlineData("object client = new HttpClient();", true, "Implicit cast construction should trigger diagnostic")]
|
||||
public async Task DiagnosticTriggered_ForVariousHttpClientConstructions(string statement, bool shouldTrigger, string reason)
|
||||
{
|
||||
var source = $$"""
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
{{statement}}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var hasDiagnostic = diagnostics.Any(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
hasDiagnostic.Should().Be(shouldTrigger, reason);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForHttpClientParameter()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run(HttpClient client)
|
||||
{
|
||||
// Using HttpClient as parameter - not constructing it
|
||||
client.GetStringAsync("https://example.com");
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Using HttpClient as parameter should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForHttpClientField()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
private HttpClient? _client;
|
||||
|
||||
public void SetClient(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Declaring HttpClient field should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_ForFactoryMethodReturn()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public interface IHttpClientFactory
|
||||
{
|
||||
HttpClient CreateClient(string name);
|
||||
}
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
private readonly IHttpClientFactory _factory;
|
||||
|
||||
public Demo(IHttpClientFactory factory) => _factory = factory;
|
||||
|
||||
public void Run()
|
||||
{
|
||||
var client = _factory.CreateClient("default");
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Using factory method should not trigger diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_InTestAssembly()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App.Tests;
|
||||
|
||||
public sealed class DemoTests
|
||||
{
|
||||
public void TestMethod()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Tests");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Test assemblies should be exempt from diagnostic");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task NoDiagnostic_InPolicyAssembly()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Internal;
|
||||
|
||||
internal static class Loopback
|
||||
{
|
||||
public static HttpClient Create() => new HttpClient();
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "StellaOps.AirGap.Policy");
|
||||
diagnostics.Should().NotContain(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId,
|
||||
"Policy assembly itself should be exempt");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Diagnostic_HasCorrectSeverity()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
airgapDiagnostic.Severity.Should().Be(DiagnosticSeverity.Warning,
|
||||
"Diagnostic should be a warning, not an error");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Diagnostic_HasCorrectLocation()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var airgapDiagnostic = diagnostics.Single(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
|
||||
airgapDiagnostic.Location.IsInSource.Should().BeTrue();
|
||||
var lineSpan = airgapDiagnostic.Location.GetLineSpan();
|
||||
lineSpan.StartLinePosition.Line.Should().Be(8, "Diagnostic should point to line 9 (0-indexed: 8)");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MultipleHttpClientUsages_ReportMultipleDiagnostics()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Method1()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
|
||||
public void Method2()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
|
||||
public void Method3()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App");
|
||||
var airgapDiagnostics = diagnostics.Where(d => d.Id == HttpClientUsageAnalyzer.DiagnosticId).ToList();
|
||||
|
||||
airgapDiagnostics.Should().HaveCount(3, "Each new HttpClient() should trigger a separate diagnostic");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AIRGAP-5100-006: Golden Generated Code Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Analyzer_SupportedDiagnostics_ContainsExpectedId()
|
||||
{
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var supportedDiagnostics = analyzer.SupportedDiagnostics;
|
||||
|
||||
supportedDiagnostics.Should().HaveCount(1);
|
||||
supportedDiagnostics[0].Id.Should().Be("AIRGAP001");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static async Task<ImmutableArray<Diagnostic>> AnalyzeAsync(string source, string assemblyName)
|
||||
{
|
||||
var compilation = CSharpCompilation.Create(
|
||||
assemblyName,
|
||||
new[]
|
||||
{
|
||||
CSharpSyntaxTree.ParseText(source),
|
||||
CSharpSyntaxTree.ParseText(PolicyStubSource),
|
||||
},
|
||||
CreateMetadataReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var analyzer = new HttpClientUsageAnalyzer();
|
||||
var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer));
|
||||
return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
|
||||
}
|
||||
|
||||
private static IEnumerable<MetadataReference> CreateMetadataReferences()
|
||||
{
|
||||
// Core runtime references
|
||||
yield return MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Uri).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(HttpClient).GetTypeInfo().Assembly.Location);
|
||||
yield return MetadataReference.CreateFromFile(typeof(Enumerable).GetTypeInfo().Assembly.Location);
|
||||
|
||||
// Add System.Runtime for target-typed new
|
||||
var systemRuntimePath = Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location);
|
||||
if (!string.IsNullOrEmpty(systemRuntimePath))
|
||||
{
|
||||
var netstandard = Path.Combine(systemRuntimePath, "netstandard.dll");
|
||||
if (File.Exists(netstandard))
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(netstandard);
|
||||
}
|
||||
|
||||
var systemRuntime = Path.Combine(systemRuntimePath, "System.Runtime.dll");
|
||||
if (File.Exists(systemRuntime))
|
||||
{
|
||||
yield return MetadataReference.CreateFromFile(systemRuntime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const string PolicyStubSource = """
|
||||
namespace StellaOps.AirGap.Policy
|
||||
{
|
||||
public interface IEgressPolicy
|
||||
{
|
||||
void EnsureAllowed(EgressRequest request);
|
||||
}
|
||||
|
||||
public readonly record struct EgressRequest(string Component, System.Uri Destination, string Intent);
|
||||
|
||||
public static class EgressHttpClientFactory
|
||||
{
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
# AirGap Policy Analyzers Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0032-M | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0032-T | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0032-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Analyzers.Tests/StellaOps.AirGap.Policy.Analyzers.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Operations;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers;
|
||||
|
||||
public sealed partial class HttpClientUsageAnalyzer
|
||||
{
|
||||
private static void AnalyzeObjectCreation(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not IObjectCreationOperation creation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClientSymbol = context.Compilation.GetTypeByMetadataName(HttpClientMetadataName);
|
||||
if (httpClientSymbol is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createdType = creation.Type;
|
||||
if (createdType is null || !SymbolEqualityComparer.Default.Equals(createdType, httpClientSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsWithinAllowedAssembly(context.ContainingSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.ReportDiagnostic(CreateDiagnostic(creation.Syntax.GetLocation()));
|
||||
}
|
||||
|
||||
private static bool IsWithinAllowedAssembly(ISymbol? symbol)
|
||||
{
|
||||
var containingAssembly = symbol?.ContainingAssembly;
|
||||
if (containingAssembly is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var assemblyName = containingAssembly.Name;
|
||||
if (string.IsNullOrEmpty(assemblyName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(assemblyName, "StellaOps.AirGap.Policy", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Test", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Testing", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Analyzers;
|
||||
|
||||
public sealed partial class HttpClientUsageAnalyzer
|
||||
{
|
||||
private const string HttpClientMetadataName = "System.Net.Http.HttpClient";
|
||||
private static readonly LocalizableString _title = "Replace raw HttpClient with EgressPolicy-aware client";
|
||||
private static readonly LocalizableString _messageFormat = "Instantiate HttpClient via StellaOps.AirGap.Policy wrappers to enforce sealed-mode egress controls";
|
||||
private static readonly LocalizableString _description = "Air-gapped environments must route outbound network calls through the EgressPolicy facade so requests are pre-authorised. Replace raw HttpClient usage with the shared factory helpers.";
|
||||
|
||||
private static readonly DiagnosticDescriptor _rule = new(
|
||||
DiagnosticId,
|
||||
_title,
|
||||
_messageFormat,
|
||||
"Usage",
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: _description);
|
||||
|
||||
private static Diagnostic CreateDiagnostic(Location location)
|
||||
=> Diagnostic.Create(_rule, location);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using Microsoft.CodeAnalysis.Operations;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
@@ -11,34 +9,21 @@ namespace StellaOps.AirGap.Policy.Analyzers;
|
||||
/// Flags direct <c>new HttpClient()</c> usage so services adopt the air-gap aware egress policy wrappers.
|
||||
/// </summary>
|
||||
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||
public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
public sealed partial class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Diagnostic identifier emitted when disallowed HttpClient usage is detected.
|
||||
/// </summary>
|
||||
public const string DiagnosticId = "AIRGAP001";
|
||||
|
||||
private const string HttpClientMetadataName = "System.Net.Http.HttpClient";
|
||||
private static readonly LocalizableString Title = "Replace raw HttpClient with EgressPolicy-aware client";
|
||||
private static readonly LocalizableString MessageFormat = "Instantiate HttpClient via StellaOps.AirGap.Policy wrappers to enforce sealed-mode egress controls";
|
||||
private static readonly LocalizableString Description = "Air-gapped environments must route outbound network calls through the EgressPolicy facade so requests are pre-authorised. Replace raw HttpClient usage with the shared factory helpers.";
|
||||
|
||||
private static readonly DiagnosticDescriptor Rule = new(
|
||||
DiagnosticId,
|
||||
Title,
|
||||
MessageFormat,
|
||||
"Usage",
|
||||
DiagnosticSeverity.Warning,
|
||||
isEnabledByDefault: true,
|
||||
description: Description);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
|
||||
=> ImmutableArray.Create(_rule);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
if (context == null)
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
@@ -47,61 +32,4 @@ public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
context.EnableConcurrentExecution();
|
||||
context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation);
|
||||
}
|
||||
|
||||
private static void AnalyzeObjectCreation(OperationAnalysisContext context)
|
||||
{
|
||||
if (context.Operation is not IObjectCreationOperation creation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClientSymbol = context.Compilation.GetTypeByMetadataName(HttpClientMetadataName);
|
||||
if (httpClientSymbol is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createdType = creation.Type;
|
||||
if (createdType is null || !SymbolEqualityComparer.Default.Equals(createdType, httpClientSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsWithinAllowedAssembly(context.ContainingSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var diagnostic = Diagnostic.Create(Rule, creation.Syntax.GetLocation());
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
|
||||
private static bool IsWithinAllowedAssembly(ISymbol? symbol)
|
||||
{
|
||||
var containingAssembly = symbol?.ContainingAssembly;
|
||||
if (containingAssembly is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var assemblyName = containingAssembly.Name;
|
||||
if (string.IsNullOrEmpty(assemblyName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(assemblyName, "StellaOps.AirGap.Policy", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Test", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Testing", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# AirGap Policy Analyzers Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0031-M | DONE | Revalidated 2026-01-06; no new findings. |
|
||||
| AUDIT-0031-T | DONE | Revalidated 2026-01-06; test coverage tracked in AUDIT-0032. |
|
||||
| AUDIT-0031-A | DONE | Applied analyzer symbol match, test assembly exemptions, and code-fix preservation. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Analyzers/StellaOps.AirGap.Policy.Analyzers.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using StellaOps.AirGap.Policy;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
private sealed class RecordingPolicy : IEgressPolicy
|
||||
{
|
||||
public bool EnsureAllowedCalled { get; private set; }
|
||||
|
||||
public bool IsSealed => true;
|
||||
|
||||
public EgressPolicyMode Mode => EgressPolicyMode.Sealed;
|
||||
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
EnsureAllowedCalled = true;
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
public ValueTask<EgressDecision> EvaluateAsync(
|
||||
EgressRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new ValueTask<EgressDecision>(Evaluate(request));
|
||||
}
|
||||
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
EnsureAllowedCalled = true;
|
||||
}
|
||||
|
||||
public ValueTask EnsureAllowedAsync(
|
||||
EgressRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureAllowed(request);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EgressHttpClientFactory_Create_EnforcesPolicyBeforeReturningClient()
|
||||
{
|
||||
var recordingPolicy = new RecordingPolicy();
|
||||
var request = new EgressRequest("Component", new Uri("https://allowed.internal"), "mirror-sync");
|
||||
|
||||
using var client = EgressHttpClientFactory.Create(recordingPolicy, request);
|
||||
|
||||
Assert.True(recordingPolicy.EnsureAllowedCalled);
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_AllowsLoopbackWhenConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowLoopback = true,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("http://127.0.0.1:9000/health"), "local-probe");
|
||||
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_AllowsPrivateNetworkWhenConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowPrivateNetworks = true,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://10.10.0.5:8443/status"), "mirror-sync");
|
||||
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_BlocksPrivateNetworkWhenNotConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowPrivateNetworks = false,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://10.10.0.5:8443/status"), "mirror-sync");
|
||||
|
||||
var exception = Assert.Throws<AirGapEgressBlockedException>(() => policy.EnsureAllowed(request));
|
||||
Assert.Contains("10.10.0.5", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("https://api.example.com", true)]
|
||||
[InlineData("https://sub.api.example.com", true)]
|
||||
[InlineData("https://example.com", false)]
|
||||
public void Evaluate_SealedEnvironmentWildcardHost_Matches(string url, bool expectedAllowed)
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
};
|
||||
options.AddAllowRule("*.example.com", transport: EgressTransport.Https);
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri(url), "mirror-sync");
|
||||
|
||||
var decision = policy.Evaluate(request);
|
||||
Assert.Equal(expectedAllowed, decision.IsAllowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceCollection_AddAirGapEgressPolicy_RegistersService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAirGapEgressPolicy(options =>
|
||||
{
|
||||
options.Mode = EgressPolicyMode.Sealed;
|
||||
options.AddAllowRule("mirror.internal", transport: EgressTransport.Https);
|
||||
});
|
||||
|
||||
var descriptor = services.Single(service => service.ServiceType == typeof(IEgressPolicy));
|
||||
Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime);
|
||||
Assert.Equal(typeof(EgressPolicy), descriptor.ImplementationType);
|
||||
|
||||
var configured = BuildConfiguredOptions(services);
|
||||
var policy = new EgressPolicy(configured);
|
||||
policy.EnsureAllowed(new EgressRequest("PolicyEngine", new Uri("https://mirror.internal"), "mirror-sync"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceCollection_AddAirGapEgressPolicy_BindsFromConfiguration()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AirGap:Egress:Mode"] = "Sealed",
|
||||
["AirGap:Egress:AllowLoopback"] = "false",
|
||||
["AirGap:Egress:AllowPrivateNetworks"] = "true",
|
||||
["AirGap:Egress:RemediationDocumentationUrl"] = "https://docs.example/airgap",
|
||||
["AirGap:Egress:SupportContact"] = "airgap@example.org",
|
||||
["AirGap:Egress:Allowlist:0:HostPattern"] = "mirror.internal",
|
||||
["AirGap:Egress:Allowlist:0:Port"] = "443",
|
||||
["AirGap:Egress:Allowlist:0:Transport"] = "https",
|
||||
["AirGap:Egress:Allowlist:0:Description"] = "Primary mirror",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddAirGapEgressPolicy(configuration);
|
||||
|
||||
var configured = BuildConfiguredOptions(services);
|
||||
Assert.Equal(EgressPolicyMode.Sealed, configured.Mode);
|
||||
Assert.Equal("https://docs.example/airgap", configured.RemediationDocumentationUrl);
|
||||
Assert.Equal("airgap@example.org", configured.SupportContact);
|
||||
|
||||
var policy = new EgressPolicy(configured);
|
||||
var decision = policy.Evaluate(new EgressRequest("ExportCenter", new Uri("https://mirror.internal/feeds"), "mirror-sync"));
|
||||
Assert.True(decision.IsAllowed);
|
||||
|
||||
var blocked = policy.Evaluate(new EgressRequest("ExportCenter", new Uri("https://external.example"), "mirror-sync"));
|
||||
Assert.False(blocked.IsAllowed);
|
||||
Assert.Contains("mirror.internal", blocked.Remediation, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static EgressPolicyOptions BuildConfiguredOptions(IServiceCollection services)
|
||||
{
|
||||
var options = new EgressPolicyOptions();
|
||||
var configurators = services
|
||||
.Where(descriptor => descriptor.ServiceType == typeof(IConfigureOptions<EgressPolicyOptions>))
|
||||
.Select(descriptor => descriptor.ImplementationInstance)
|
||||
.OfType<IConfigureOptions<EgressPolicyOptions>>()
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(configurators);
|
||||
|
||||
foreach (var configurator in configurators)
|
||||
{
|
||||
configurator.Configure(options);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using StellaOps.TestKit;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AirGap.Policy.Tests;
|
||||
|
||||
public sealed class EgressPolicyTests
|
||||
public sealed partial class EgressPolicyTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void Evaluate_UnsealedEnvironment_AllowsRequest()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
@@ -33,7 +26,7 @@ public sealed class EgressPolicyTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironmentWithMatchingRule_Allows()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
@@ -49,7 +42,7 @@ public sealed class EgressPolicyTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironmentWithoutRule_ThrowsWithGuidance()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
@@ -71,171 +64,4 @@ public sealed class EgressPolicyTests
|
||||
Assert.Equal(options.RemediationDocumentationUrl, exception.DocumentationUrl);
|
||||
Assert.Equal(options.SupportContact, exception.SupportContact);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_AllowsLoopbackWhenConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowLoopback = true,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("http://127.0.0.1:9000/health"), "local-probe");
|
||||
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_AllowsPrivateNetworkWhenConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowPrivateNetworks = true,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://10.10.0.5:8443/status"), "mirror-sync");
|
||||
|
||||
policy.EnsureAllowed(request);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EnsureAllowed_SealedEnvironment_BlocksPrivateNetworkWhenNotConfigured()
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
AllowPrivateNetworks = false,
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri("https://10.10.0.5:8443/status"), "mirror-sync");
|
||||
|
||||
var exception = Assert.Throws<AirGapEgressBlockedException>(() => policy.EnsureAllowed(request));
|
||||
Assert.Contains("10.10.0.5", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("https://api.example.com", true)]
|
||||
[InlineData("https://sub.api.example.com", true)]
|
||||
[InlineData("https://example.com", false)]
|
||||
public void Evaluate_SealedEnvironmentWildcardHost_Matches(string url, bool expectedAllowed)
|
||||
{
|
||||
var options = new EgressPolicyOptions
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed,
|
||||
};
|
||||
options.AddAllowRule("*.example.com", transport: EgressTransport.Https);
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var request = new EgressRequest("PolicyEngine", new Uri(url), "mirror-sync");
|
||||
|
||||
var decision = policy.Evaluate(request);
|
||||
Assert.Equal(expectedAllowed, decision.IsAllowed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceCollection_AddAirGapEgressPolicy_RegistersService()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddAirGapEgressPolicy(options =>
|
||||
{
|
||||
options.Mode = EgressPolicyMode.Sealed;
|
||||
options.AddAllowRule("mirror.internal", transport: EgressTransport.Https);
|
||||
});
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var policy = provider.GetRequiredService<IEgressPolicy>();
|
||||
|
||||
Assert.True(policy.IsSealed);
|
||||
policy.EnsureAllowed(new EgressRequest("PolicyEngine", new Uri("https://mirror.internal"), "mirror-sync"));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ServiceCollection_AddAirGapEgressPolicy_BindsFromConfiguration()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AirGap:Egress:Mode"] = "Sealed",
|
||||
["AirGap:Egress:AllowLoopback"] = "false",
|
||||
["AirGap:Egress:AllowPrivateNetworks"] = "true",
|
||||
["AirGap:Egress:RemediationDocumentationUrl"] = "https://docs.example/airgap",
|
||||
["AirGap:Egress:SupportContact"] = "airgap@example.org",
|
||||
["AirGap:Egress:Allowlist:0:HostPattern"] = "mirror.internal",
|
||||
["AirGap:Egress:Allowlist:0:Port"] = "443",
|
||||
["AirGap:Egress:Allowlist:0:Transport"] = "https",
|
||||
["AirGap:Egress:Allowlist:0:Description"] = "Primary mirror",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddAirGapEgressPolicy(configuration);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var policy = provider.GetRequiredService<IEgressPolicy>();
|
||||
|
||||
Assert.True(policy.IsSealed);
|
||||
var decision = policy.Evaluate(new EgressRequest("ExportCenter", new Uri("https://mirror.internal/feeds"), "mirror-sync"));
|
||||
Assert.True(decision.IsAllowed);
|
||||
|
||||
var blocked = policy.Evaluate(new EgressRequest("ExportCenter", new Uri("https://external.example"), "mirror-sync"));
|
||||
Assert.False(blocked.IsAllowed);
|
||||
Assert.Contains("mirror.internal", blocked.Remediation, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void EgressHttpClientFactory_Create_EnforcesPolicyBeforeReturningClient()
|
||||
{
|
||||
var recordingPolicy = new RecordingPolicy();
|
||||
var request = new EgressRequest("Component", new Uri("https://allowed.internal"), "mirror-sync");
|
||||
|
||||
using var client = EgressHttpClientFactory.Create(recordingPolicy, request);
|
||||
|
||||
Assert.True(recordingPolicy.EnsureAllowedCalled);
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
private sealed class RecordingPolicy : IEgressPolicy
|
||||
{
|
||||
public bool EnsureAllowedCalled { get; private set; }
|
||||
|
||||
public bool IsSealed => true;
|
||||
|
||||
public EgressPolicyMode Mode => EgressPolicyMode.Sealed;
|
||||
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
EnsureAllowedCalled = true;
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new ValueTask<EgressDecision>(Evaluate(request));
|
||||
}
|
||||
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
EnsureAllowedCalled = true;
|
||||
}
|
||||
|
||||
public ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EnsureAllowed(request);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# AirGap Policy Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0033-M | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0033-T | DONE | Revalidated 2026-01-06; findings recorded in audit report. |
|
||||
| AUDIT-0033-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.Tests/StellaOps.AirGap.Policy.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class AirGapEgressBlockedException
|
||||
{
|
||||
private static string BuildMessage(
|
||||
EgressRequest request,
|
||||
string reason,
|
||||
string remediation,
|
||||
string? documentationUrl,
|
||||
string? supportContact)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(ErrorCode)
|
||||
.Append(": component '")
|
||||
.Append(request.Component)
|
||||
.Append("' attempted to reach '")
|
||||
.Append(request.Destination)
|
||||
.Append("' (intent: ")
|
||||
.Append(request.Intent);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Operation))
|
||||
{
|
||||
builder.Append(", operation: ")
|
||||
.Append(request.Operation);
|
||||
}
|
||||
|
||||
builder.Append("). Reason: ")
|
||||
.Append(reason)
|
||||
.Append(". Remediation: ")
|
||||
.Append(remediation);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(documentationUrl))
|
||||
{
|
||||
builder.Append(" Documentation: ")
|
||||
.Append(documentationUrl);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(supportContact))
|
||||
{
|
||||
builder.Append(" Contact: ")
|
||||
.Append(supportContact);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Exception raised when an egress operation is blocked while sealed mode is active.
|
||||
/// </summary>
|
||||
public sealed class AirGapEgressBlockedException : InvalidOperationException
|
||||
public sealed partial class AirGapEgressBlockedException : InvalidOperationException
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code surfaced to callers when egress is blocked.
|
||||
@@ -60,41 +59,4 @@ public sealed class AirGapEgressBlockedException : InvalidOperationException
|
||||
/// Gets an optional support contact (for example, an on-call alias).
|
||||
/// </summary>
|
||||
public string? SupportContact { get; }
|
||||
|
||||
private static string BuildMessage(EgressRequest request, string reason, string remediation, string? documentationUrl, string? supportContact)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(ErrorCode)
|
||||
.Append(": component '")
|
||||
.Append(request.Component)
|
||||
.Append("' attempted to reach '")
|
||||
.Append(request.Destination)
|
||||
.Append("' (intent: ")
|
||||
.Append(request.Intent);
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Operation))
|
||||
{
|
||||
builder.Append(", operation: ")
|
||||
.Append(request.Operation);
|
||||
}
|
||||
|
||||
builder.Append("). Reason: ")
|
||||
.Append(reason)
|
||||
.Append(". Remediation: ")
|
||||
.Append(remediation);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(documentationUrl))
|
||||
{
|
||||
builder.Append(" Documentation: ")
|
||||
.Append(documentationUrl);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(supportContact))
|
||||
{
|
||||
builder.Append(" Contact: ")
|
||||
.Append(supportContact);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressPolicy
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
if (!HasValidDestination(request))
|
||||
{
|
||||
return EgressDecision.Blocked(
|
||||
"Egress request is missing a valid destination URI.",
|
||||
BuildInvalidRequestRemediation(request));
|
||||
}
|
||||
|
||||
var options = Volatile.Read(ref _options);
|
||||
var rules = Volatile.Read(ref _rules);
|
||||
|
||||
if (!IsSealed)
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (options.AllowLoopback && IsLoopback(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (rule.Allows(request))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
}
|
||||
|
||||
var destinationLabel = request.Destination?.Host ?? "unknown-host";
|
||||
var reason = $"Destination '{destinationLabel}' is not present in the sealed-mode allow list.";
|
||||
var remediation = BuildRemediation(request, rules);
|
||||
return EgressDecision.Blocked(reason, remediation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<EgressDecision> EvaluateAsync(
|
||||
EgressRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return ValueTask.FromResult(Evaluate(request));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
var decision = Evaluate(request);
|
||||
if (decision.IsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw CreateException(request, decision);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask EnsureAllowedAsync(
|
||||
EgressRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var decision = await EvaluateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!decision.IsAllowed)
|
||||
{
|
||||
throw CreateException(request, decision);
|
||||
}
|
||||
}
|
||||
|
||||
private AirGapEgressBlockedException CreateException(EgressRequest request, EgressDecision decision)
|
||||
=> new(
|
||||
request,
|
||||
decision.Reason ?? "Egress blocked.",
|
||||
decision.Remediation ?? BuildRemediation(request, Volatile.Read(ref _rules)),
|
||||
Volatile.Read(ref _options).RemediationDocumentationUrl,
|
||||
Volatile.Read(ref _options).SupportContact);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressPolicy
|
||||
{
|
||||
private static bool HasValidDestination(EgressRequest request)
|
||||
=> request.Destination is { IsAbsoluteUri: true };
|
||||
|
||||
private static bool IsLoopback(Uri destination)
|
||||
{
|
||||
if (string.Equals(destination.Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IPAddress.TryParse(destination.Host, out var address))
|
||||
{
|
||||
return IPAddress.IsLoopback(address);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsPrivateNetwork(Uri destination)
|
||||
{
|
||||
if (!IPAddress.TryParse(destination.Host, out var address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true,
|
||||
172 => bytes[1] >= 16 && bytes[1] <= 31,
|
||||
192 => bytes[1] == 168,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
var isUniqueLocal = bytes.Length > 0 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7
|
||||
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || isUniqueLocal;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressPolicy
|
||||
{
|
||||
private void ApplyOptions(EgressPolicyOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var rules = options.BuildRuleSet();
|
||||
Volatile.Write(ref _rules, rules);
|
||||
Volatile.Write(ref _options, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressPolicy
|
||||
{
|
||||
private string BuildRemediation(EgressRequest request, EgressRule[] rules)
|
||||
{
|
||||
var host = request.Destination?.Host;
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
host = "unknown-host";
|
||||
}
|
||||
|
||||
var portSegment = request.Destination is { IsDefaultPort: false }
|
||||
? $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}"
|
||||
: string.Empty;
|
||||
var transport = request.Transport.ToString().ToUpperInvariant();
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("Add '")
|
||||
.Append(host)
|
||||
.Append(portSegment)
|
||||
.Append("' (")
|
||||
.Append(transport)
|
||||
.Append(") to the airgap.egressAllowlist configuration.");
|
||||
|
||||
if (rules.Length == 0)
|
||||
{
|
||||
builder.Append(" No allow entries are currently configured; sealed mode blocks every external host.");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(" Current allow list sample: ");
|
||||
var limit = Math.Min(rules.Length, 3);
|
||||
for (var i = 0; i < limit; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
builder.Append(", ");
|
||||
}
|
||||
|
||||
builder.Append(rules[i].HostPattern);
|
||||
if (rules[i].Port is int port)
|
||||
{
|
||||
builder.Append(':')
|
||||
.Append(port.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.Length > limit)
|
||||
{
|
||||
builder.Append(", ...");
|
||||
}
|
||||
|
||||
builder.Append(". Coordinate break-glass with platform operations before expanding access.");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildInvalidRequestRemediation(EgressRequest request)
|
||||
{
|
||||
var component = string.IsNullOrWhiteSpace(request.Component) ? "unknown-component" : request.Component;
|
||||
var intent = string.IsNullOrWhiteSpace(request.Intent) ? "unknown-intent" : request.Intent;
|
||||
return $"Provide an absolute destination URI for component '{component}' (intent: {intent}) before evaluating sealed-mode egress.";
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IEgressPolicy"/>.
|
||||
/// </summary>
|
||||
public sealed class EgressPolicy : IEgressPolicy
|
||||
public sealed partial class EgressPolicy : IEgressPolicy
|
||||
{
|
||||
private readonly IDisposable? _optionsSubscription;
|
||||
private EgressRule[] _rules = Array.Empty<EgressRule>();
|
||||
@@ -27,7 +23,8 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressPolicy"/> class with reload support.
|
||||
/// Initializes a new instance of the <see cref="EgressPolicy"/> class with
|
||||
/// reload support.
|
||||
/// </summary>
|
||||
/// <param name="optionsMonitor">Options monitor that supplies updated policy settings.</param>
|
||||
public EgressPolicy(IOptionsMonitor<EgressPolicyOptions> optionsMonitor)
|
||||
@@ -43,202 +40,4 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
|
||||
/// <inheritdoc />
|
||||
public EgressPolicyMode Mode => Volatile.Read(ref _options).Mode;
|
||||
|
||||
/// <inheritdoc />
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
if (!HasValidDestination(request))
|
||||
{
|
||||
return EgressDecision.Blocked(
|
||||
"Egress request is missing a valid destination URI.",
|
||||
BuildInvalidRequestRemediation(request));
|
||||
}
|
||||
|
||||
var options = Volatile.Read(ref _options);
|
||||
var rules = Volatile.Read(ref _rules);
|
||||
|
||||
if (!IsSealed)
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (options.AllowLoopback && IsLoopback(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (rule.Allows(request))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
}
|
||||
|
||||
var destinationLabel = request.Destination?.Host ?? "unknown-host";
|
||||
var reason = $"Destination '{destinationLabel}' is not present in the sealed-mode allow list.";
|
||||
var remediation = BuildRemediation(request, rules);
|
||||
return EgressDecision.Blocked(reason, remediation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return ValueTask.FromResult(Evaluate(request));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EnsureAllowed(EgressRequest request)
|
||||
{
|
||||
var decision = Evaluate(request);
|
||||
if (decision.IsAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw CreateException(request, decision);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var decision = await EvaluateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!decision.IsAllowed)
|
||||
{
|
||||
throw CreateException(request, decision);
|
||||
}
|
||||
}
|
||||
|
||||
private AirGapEgressBlockedException CreateException(EgressRequest request, EgressDecision decision)
|
||||
=> new(
|
||||
request,
|
||||
decision.Reason ?? "Egress blocked.",
|
||||
decision.Remediation ?? BuildRemediation(request, Volatile.Read(ref _rules)),
|
||||
Volatile.Read(ref _options).RemediationDocumentationUrl,
|
||||
Volatile.Read(ref _options).SupportContact);
|
||||
|
||||
private string BuildRemediation(EgressRequest request, EgressRule[] rules)
|
||||
{
|
||||
var host = request.Destination?.Host;
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
host = "unknown-host";
|
||||
}
|
||||
|
||||
var portSegment = request.Destination is { IsDefaultPort: false }
|
||||
? $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}"
|
||||
: string.Empty;
|
||||
var transport = request.Transport.ToString().ToUpperInvariant();
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
builder.Append("Add '")
|
||||
.Append(host)
|
||||
.Append(portSegment)
|
||||
.Append("' (")
|
||||
.Append(transport)
|
||||
.Append(") to the airgap.egressAllowlist configuration.");
|
||||
|
||||
if (rules.Length == 0)
|
||||
{
|
||||
builder.Append(" No allow entries are currently configured; sealed mode blocks every external host.");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(" Current allow list sample: ");
|
||||
var limit = Math.Min(rules.Length, 3);
|
||||
for (var i = 0; i < limit; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
builder.Append(", ");
|
||||
}
|
||||
|
||||
builder.Append(rules[i].HostPattern);
|
||||
if (rules[i].Port is int port)
|
||||
{
|
||||
builder.Append(':')
|
||||
.Append(port.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (rules.Length > limit)
|
||||
{
|
||||
builder.Append(", ...");
|
||||
}
|
||||
|
||||
builder.Append(". Coordinate break-glass with platform operations before expanding access.");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildInvalidRequestRemediation(EgressRequest request)
|
||||
{
|
||||
var component = string.IsNullOrWhiteSpace(request.Component) ? "unknown-component" : request.Component;
|
||||
var intent = string.IsNullOrWhiteSpace(request.Intent) ? "unknown-intent" : request.Intent;
|
||||
return $"Provide an absolute destination URI for component '{component}' (intent: {intent}) before evaluating sealed-mode egress.";
|
||||
}
|
||||
|
||||
private static bool HasValidDestination(EgressRequest request)
|
||||
=> request.Destination is { IsAbsoluteUri: true };
|
||||
|
||||
private static bool IsLoopback(Uri destination)
|
||||
{
|
||||
if (string.Equals(destination.Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IPAddress.TryParse(destination.Host, out var address))
|
||||
{
|
||||
return IPAddress.IsLoopback(address);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsPrivateNetwork(Uri destination)
|
||||
{
|
||||
if (!IPAddress.TryParse(destination.Host, out var address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true,
|
||||
172 => bytes[1] >= 16 && bytes[1] <= 31,
|
||||
192 => bytes[1] == 168,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
var isUniqueLocal = bytes.Length > 0 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7
|
||||
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || isUniqueLocal;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyOptions(EgressPolicyOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var rules = options.BuildRuleSet();
|
||||
Volatile.Write(ref _rules, rules);
|
||||
Volatile.Write(ref _options, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowRuleSections(
|
||||
IConfiguration effective,
|
||||
IConfiguration primary,
|
||||
IConfiguration root)
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(effective))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(primary, effective))
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(primary))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(root, effective) && !ReferenceEquals(root, primary))
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(root))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowRuleSections(IConfiguration configuration)
|
||||
{
|
||||
foreach (var candidate in EnumerateAllowlistContainers(configuration))
|
||||
{
|
||||
if (!candidate.Exists())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var child in candidate.GetChildren())
|
||||
{
|
||||
yield return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowlistContainers(IConfiguration configuration)
|
||||
{
|
||||
yield return configuration.GetSection("Allowlist");
|
||||
yield return configuration.GetSection("AllowList");
|
||||
yield return configuration.GetSection("EgressAllowlist");
|
||||
yield return configuration.GetSection("Allow");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
private static IConfiguration ResolveConfigurationSection(IConfiguration configuration, string? sectionName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(sectionName))
|
||||
{
|
||||
var namedSection = configuration.GetSection(sectionName);
|
||||
if (namedSection.Exists())
|
||||
{
|
||||
return namedSection;
|
||||
}
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
private static void ApplyConfiguration(
|
||||
EgressPolicyOptions options,
|
||||
IConfiguration primarySection,
|
||||
IConfiguration root)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(primarySection);
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
|
||||
var effectiveSection = ResolveEffectiveSection(primarySection);
|
||||
var searchOrder = BuildSearchOrder(effectiveSection, primarySection, root);
|
||||
|
||||
var modeValue = GetStringValue(searchOrder, "Mode");
|
||||
if (!string.IsNullOrWhiteSpace(modeValue) &&
|
||||
Enum.TryParse(modeValue, ignoreCase: true, out EgressPolicyMode parsedMode))
|
||||
{
|
||||
options.Mode = parsedMode;
|
||||
}
|
||||
|
||||
var allowLoopback = GetNullableBool(searchOrder, "AllowLoopback");
|
||||
if (allowLoopback.HasValue)
|
||||
{
|
||||
options.AllowLoopback = allowLoopback.Value;
|
||||
}
|
||||
|
||||
var allowPrivateNetworks = GetNullableBool(searchOrder, "AllowPrivateNetworks");
|
||||
if (allowPrivateNetworks.HasValue)
|
||||
{
|
||||
options.AllowPrivateNetworks = allowPrivateNetworks.Value;
|
||||
}
|
||||
|
||||
var remediationUrl = GetStringValue(searchOrder, "RemediationDocumentationUrl");
|
||||
if (!string.IsNullOrWhiteSpace(remediationUrl))
|
||||
{
|
||||
options.RemediationDocumentationUrl = remediationUrl.Trim();
|
||||
}
|
||||
|
||||
var supportContact = GetStringValue(searchOrder, "SupportContact");
|
||||
if (!string.IsNullOrWhiteSpace(supportContact))
|
||||
{
|
||||
options.SupportContact = supportContact.Trim();
|
||||
}
|
||||
|
||||
var rules = new List<EgressRule>();
|
||||
var seenRules = new HashSet<RuleKey>();
|
||||
foreach (var ruleSection in EnumerateAllowRuleSections(effectiveSection, primarySection, root))
|
||||
{
|
||||
var hostPattern = ruleSection["HostPattern"]
|
||||
?? ruleSection["Host"]
|
||||
?? ruleSection["Pattern"]
|
||||
?? ruleSection.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(hostPattern))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hostPattern = hostPattern.Trim();
|
||||
var port = TryReadPort(ruleSection);
|
||||
var transport = ParseTransport(ruleSection["Transport"] ?? ruleSection["Protocol"]);
|
||||
|
||||
var description = ruleSection["Description"] ?? ruleSection["Notes"];
|
||||
description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||
|
||||
var ruleKey = RuleKey.Create(hostPattern, port, transport);
|
||||
if (seenRules.Add(ruleKey))
|
||||
{
|
||||
rules.Add(new EgressRule(hostPattern, port, transport, description));
|
||||
}
|
||||
}
|
||||
|
||||
options.SetAllowRules(rules);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
private static IConfiguration ResolveEffectiveSection(IConfiguration configuration)
|
||||
{
|
||||
var egressSection = configuration.GetSection("Egress");
|
||||
return egressSection.Exists() ? egressSection : configuration;
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfiguration> BuildSearchOrder(
|
||||
IConfiguration effective,
|
||||
IConfiguration primary,
|
||||
IConfiguration root)
|
||||
{
|
||||
yield return effective;
|
||||
|
||||
if (!ReferenceEquals(primary, effective))
|
||||
{
|
||||
yield return primary;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(root, effective) && !ReferenceEquals(root, primary))
|
||||
{
|
||||
yield return root;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
private static string? GetStringValue(IEnumerable<IConfiguration> sections, string key)
|
||||
{
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var value = section[key];
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? GetNullableBool(IEnumerable<IConfiguration> sections, string key)
|
||||
{
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var value = section[key];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? TryReadPort(IConfiguration section)
|
||||
{
|
||||
var raw = section["Port"];
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static EgressTransport ParseTransport(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return EgressTransport.Any;
|
||||
}
|
||||
|
||||
return Enum.TryParse(value, ignoreCase: true, out EgressTransport parsed)
|
||||
? parsed
|
||||
: EgressTransport.Any;
|
||||
}
|
||||
|
||||
private readonly record struct RuleKey(string HostPattern, int? Port, EgressTransport Transport)
|
||||
{
|
||||
public static RuleKey Create(string hostPattern, int? port, EgressTransport transport)
|
||||
=> new(hostPattern.Trim().ToLowerInvariant(), port, transport);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection helpers for configuring the air-gap egress policy.
|
||||
/// </summary>
|
||||
public static class EgressPolicyServiceCollectionExtensions
|
||||
public static partial class EgressPolicyServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers <see cref="IEgressPolicy"/> using the provided configuration delegate.
|
||||
@@ -20,7 +16,9 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
/// <param name="services">Service collection that will be updated.</param>
|
||||
/// <param name="configure">Optional configuration delegate.</param>
|
||||
/// <returns>The original <see cref="IServiceCollection"/>.</returns>
|
||||
public static IServiceCollection AddAirGapEgressPolicy(this IServiceCollection services, Action<EgressPolicyOptions>? configure = null)
|
||||
public static IServiceCollection AddAirGapEgressPolicy(
|
||||
this IServiceCollection services,
|
||||
Action<EgressPolicyOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
@@ -33,11 +31,7 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
services.AddOptions<EgressPolicyOptions>().Configure(configure);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IEgressPolicy>(sp =>
|
||||
{
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<EgressPolicyOptions>>();
|
||||
return new EgressPolicy(optionsMonitor);
|
||||
});
|
||||
services.TryAddSingleton<IEgressPolicy, EgressPolicy>();
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -67,228 +61,4 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
ApplyConfiguration(options, targetSection, configuration);
|
||||
});
|
||||
}
|
||||
|
||||
private static IConfiguration ResolveConfigurationSection(IConfiguration configuration, string? sectionName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(sectionName))
|
||||
{
|
||||
var namedSection = configuration.GetSection(sectionName);
|
||||
if (namedSection.Exists())
|
||||
{
|
||||
return namedSection;
|
||||
}
|
||||
}
|
||||
|
||||
return configuration;
|
||||
}
|
||||
|
||||
private static void ApplyConfiguration(EgressPolicyOptions options, IConfiguration primarySection, IConfiguration root)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(primarySection);
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
|
||||
var effectiveSection = ResolveEffectiveSection(primarySection);
|
||||
var searchOrder = BuildSearchOrder(effectiveSection, primarySection, root);
|
||||
|
||||
var modeValue = GetStringValue(searchOrder, "Mode");
|
||||
if (!string.IsNullOrWhiteSpace(modeValue) &&
|
||||
Enum.TryParse(modeValue, ignoreCase: true, out EgressPolicyMode parsedMode))
|
||||
{
|
||||
options.Mode = parsedMode;
|
||||
}
|
||||
|
||||
var allowLoopback = GetNullableBool(searchOrder, "AllowLoopback");
|
||||
if (allowLoopback.HasValue)
|
||||
{
|
||||
options.AllowLoopback = allowLoopback.Value;
|
||||
}
|
||||
|
||||
var allowPrivateNetworks = GetNullableBool(searchOrder, "AllowPrivateNetworks");
|
||||
if (allowPrivateNetworks.HasValue)
|
||||
{
|
||||
options.AllowPrivateNetworks = allowPrivateNetworks.Value;
|
||||
}
|
||||
|
||||
var remediationUrl = GetStringValue(searchOrder, "RemediationDocumentationUrl");
|
||||
if (!string.IsNullOrWhiteSpace(remediationUrl))
|
||||
{
|
||||
options.RemediationDocumentationUrl = remediationUrl.Trim();
|
||||
}
|
||||
|
||||
var supportContact = GetStringValue(searchOrder, "SupportContact");
|
||||
if (!string.IsNullOrWhiteSpace(supportContact))
|
||||
{
|
||||
options.SupportContact = supportContact.Trim();
|
||||
}
|
||||
|
||||
var rules = new List<EgressRule>();
|
||||
var seenRules = new HashSet<RuleKey>();
|
||||
foreach (var ruleSection in EnumerateAllowRuleSections(effectiveSection, primarySection, root))
|
||||
{
|
||||
var hostPattern = ruleSection["HostPattern"]
|
||||
?? ruleSection["Host"]
|
||||
?? ruleSection["Pattern"]
|
||||
?? ruleSection.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(hostPattern))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hostPattern = hostPattern.Trim();
|
||||
var port = TryReadPort(ruleSection);
|
||||
var transport = ParseTransport(ruleSection["Transport"] ?? ruleSection["Protocol"]);
|
||||
|
||||
var description = ruleSection["Description"] ?? ruleSection["Notes"];
|
||||
description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||
|
||||
var ruleKey = RuleKey.Create(hostPattern, port, transport);
|
||||
if (seenRules.Add(ruleKey))
|
||||
{
|
||||
rules.Add(new EgressRule(hostPattern, port, transport, description));
|
||||
}
|
||||
}
|
||||
|
||||
options.SetAllowRules(rules);
|
||||
}
|
||||
|
||||
private static IConfiguration ResolveEffectiveSection(IConfiguration configuration)
|
||||
{
|
||||
var egressSection = configuration.GetSection("Egress");
|
||||
return egressSection.Exists() ? egressSection : configuration;
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfiguration> BuildSearchOrder(
|
||||
IConfiguration effective,
|
||||
IConfiguration primary,
|
||||
IConfiguration root)
|
||||
{
|
||||
yield return effective;
|
||||
|
||||
if (!ReferenceEquals(primary, effective))
|
||||
{
|
||||
yield return primary;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(root, effective) && !ReferenceEquals(root, primary))
|
||||
{
|
||||
yield return root;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetStringValue(IEnumerable<IConfiguration> sections, string key)
|
||||
{
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var value = section[key];
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? GetNullableBool(IEnumerable<IConfiguration> sections, string key)
|
||||
{
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var value = section[key];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bool.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowRuleSections(
|
||||
IConfiguration effective,
|
||||
IConfiguration primary,
|
||||
IConfiguration root)
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(effective))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(primary, effective))
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(primary))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(root, effective) && !ReferenceEquals(root, primary))
|
||||
{
|
||||
foreach (var rule in EnumerateAllowRuleSections(root))
|
||||
{
|
||||
yield return rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowRuleSections(IConfiguration configuration)
|
||||
{
|
||||
foreach (var candidate in EnumerateAllowlistContainers(configuration))
|
||||
{
|
||||
if (!candidate.Exists())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var child in candidate.GetChildren())
|
||||
{
|
||||
yield return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<IConfigurationSection> EnumerateAllowlistContainers(IConfiguration configuration)
|
||||
{
|
||||
yield return configuration.GetSection("Allowlist");
|
||||
yield return configuration.GetSection("AllowList");
|
||||
yield return configuration.GetSection("EgressAllowlist");
|
||||
yield return configuration.GetSection("Allow");
|
||||
}
|
||||
|
||||
private static int? TryReadPort(IConfiguration section)
|
||||
{
|
||||
var raw = section["Port"];
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static EgressTransport ParseTransport(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return EgressTransport.Any;
|
||||
}
|
||||
|
||||
return Enum.TryParse(value, ignoreCase: true, out EgressTransport parsed)
|
||||
? parsed
|
||||
: EgressTransport.Any;
|
||||
}
|
||||
|
||||
private readonly record struct RuleKey(string HostPattern, int? Port, EgressTransport Transport)
|
||||
{
|
||||
public static RuleKey Create(string hostPattern, int? port, EgressTransport transport)
|
||||
=> new(hostPattern.Trim().ToLowerInvariant(), port, transport);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressRule
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
=> Port is null
|
||||
? $"{_hostPattern} ({Transport})"
|
||||
: $"{_hostPattern}:{Port} ({Transport})";
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
public sealed partial class EgressRule
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the rule allows the supplied request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request that will be evaluated.</param>
|
||||
/// <returns><see langword="true"/> when the request is allowed; otherwise <see langword="false"/>.</returns>
|
||||
public bool Allows(EgressRequest request)
|
||||
{
|
||||
if (request.Destination is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Transport != EgressTransport.Any && Transport != request.Transport)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HostMatches(request.Destination.Host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Port is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var requestPort = request.Destination.Port;
|
||||
return requestPort == Port.Value;
|
||||
}
|
||||
|
||||
private bool HostMatches(string host)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = host.ToLowerInvariant();
|
||||
|
||||
if (_wildcardAnyHost)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_wildcardSuffix is not null)
|
||||
{
|
||||
if (!normalized.EndsWith(_wildcardSuffix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remainderLength = normalized.Length - _wildcardSuffix.Length;
|
||||
return remainderLength > 0;
|
||||
}
|
||||
|
||||
return string.Equals(normalized, _hostPattern, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace StellaOps.AirGap.Policy;
|
||||
/// <summary>
|
||||
/// Represents a single allow entry used when sealed mode is active.
|
||||
/// </summary>
|
||||
public sealed class EgressRule
|
||||
public sealed partial class EgressRule
|
||||
{
|
||||
private readonly string _hostPattern;
|
||||
private readonly string? _wildcardSuffix;
|
||||
@@ -59,69 +59,4 @@ public sealed class EgressRule
|
||||
/// Gets the transport classification required for the rule.
|
||||
/// </summary>
|
||||
public EgressTransport Transport { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the rule allows the supplied request.
|
||||
/// </summary>
|
||||
/// <param name="request">The request that will be evaluated.</param>
|
||||
/// <returns><see langword="true"/> when the request is allowed; otherwise <see langword="false"/>.</returns>
|
||||
public bool Allows(EgressRequest request)
|
||||
{
|
||||
if (request.Destination is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Transport != EgressTransport.Any && Transport != request.Transport)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HostMatches(request.Destination.Host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Port is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var requestPort = request.Destination.Port;
|
||||
return requestPort == Port.Value;
|
||||
}
|
||||
|
||||
private bool HostMatches(string host)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = host.ToLowerInvariant();
|
||||
|
||||
if (_wildcardAnyHost)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_wildcardSuffix is not null)
|
||||
{
|
||||
if (!normalized.EndsWith(_wildcardSuffix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remainderLength = normalized.Length - _wildcardSuffix.Length;
|
||||
return remainderLength > 0;
|
||||
}
|
||||
|
||||
return string.Equals(normalized, _hostPattern, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
=> Port is null
|
||||
? $"{_hostPattern} ({Transport})"
|
||||
: $"{_hostPattern}:{Port} ({Transport})";
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# AirGap Policy Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md` and `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0030-M | DONE | Revalidated 2026-01-06; new findings recorded in audit report. |
|
||||
| AUDIT-0030-T | DONE | Revalidated 2026-01-06; test coverage tracked in AUDIT-0033. |
|
||||
| AUDIT-0030-A | TODO | Replace direct new HttpClient usage in EgressHttpClientFactory. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.md. |
|
||||
| REMED-06 | DONE | SOLID review notes updated for SPRINT_20260130_002. |
|
||||
|
||||
@@ -29,19 +29,19 @@ public class TimeStatusController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<TimeStatusDto>> GetStatus([FromQuery] string tenantId)
|
||||
public async Task<ActionResult<TimeStatusDto>> GetStatusAsync([FromQuery] string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return BadRequest("tenantId-required");
|
||||
}
|
||||
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted);
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
|
||||
[HttpPost("anchor")]
|
||||
public async Task<ActionResult<TimeStatusDto>> SetAnchor([FromBody] SetAnchorRequest request)
|
||||
public async Task<ActionResult<TimeStatusDto>> SetAnchorAsync([FromBody] SetAnchorRequest request)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
@@ -78,9 +78,9 @@ public class TimeStatusController : ControllerBase
|
||||
request.WarningSeconds ?? StalenessBudget.Default.WarningSeconds,
|
||||
request.BreachSeconds ?? StalenessBudget.Default.BreachSeconds);
|
||||
|
||||
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
|
||||
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
_logger.LogInformation("Time anchor set for tenant {Tenant} format={Format} digest={Digest} warning={Warning}s breach={Breach}s", request.TenantId, anchor.Format, anchor.TokenDigest, budget.WarningSeconds, budget.BreachSeconds);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed class TimeAnchorHealthCheck : IHealthCheck
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
var status = await _statusService.GetStatusAsync(opts.TenantId, _timeProvider.GetUtcNow(), cancellationToken);
|
||||
var status = await _statusService.GetStatusAsync(opts.TenantId, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Hooks;
|
||||
|
||||
public sealed class SealedStartupHostedService : IHostedService
|
||||
{
|
||||
private readonly SealedStartupValidator _validator;
|
||||
private readonly IOptions<AirGapOptions> _options;
|
||||
private readonly ILogger<SealedStartupHostedService> _logger;
|
||||
|
||||
public SealedStartupHostedService(
|
||||
SealedStartupValidator validator,
|
||||
IOptions<AirGapOptions> options,
|
||||
ILogger<SealedStartupHostedService> logger)
|
||||
{
|
||||
_validator = validator;
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
var tenantId = opts.TenantId;
|
||||
var budget = new StalenessBudget(opts.Staleness.WarningSeconds, opts.Staleness.BreachSeconds);
|
||||
|
||||
_logger.LogInformation(
|
||||
"AirGap Time starting for tenant {Tenant} with budgets warning={Warning}s breach={Breach}s",
|
||||
tenantId,
|
||||
budget.WarningSeconds,
|
||||
budget.BreachSeconds);
|
||||
|
||||
var result = await _validator.ValidateAsync(tenantId, budget, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
_logger.LogCritical(
|
||||
"AirGap time validation failed: {Reason} (tenant {TenantId})",
|
||||
result.Reason,
|
||||
tenantId);
|
||||
throw new InvalidOperationException($"sealed-startup-blocked:{result.Reason}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AirGap time validation passed: anchor={Anchor} age={Age}s tenant={Tenant}",
|
||||
result.Status?.Anchor.TokenDigest,
|
||||
result.Status?.Staleness.AgeSeconds,
|
||||
tenantId);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Hooks;
|
||||
|
||||
public static class StartupValidationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs sealed-mode time anchor validation during app startup; aborts if missing or stale.
|
||||
/// </summary>
|
||||
public static IHost ValidateTimeAnchorOnStart(this IHost host, string tenantId, StalenessBudget budget)
|
||||
{
|
||||
using var scope = host.Services.CreateScope();
|
||||
var validator = scope.ServiceProvider.GetRequiredService<SealedStartupValidator>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("AirGap.Time.Startup");
|
||||
|
||||
var result = validator.ValidateAsync(tenantId, budget, CancellationToken.None).GetAwaiter().GetResult();
|
||||
if (!result.IsValid)
|
||||
{
|
||||
logger.LogCritical("AirGap time validation failed: {Reason} (tenant {TenantId})", result.Reason, tenantId);
|
||||
throw new InvalidOperationException($"sealed-startup-blocked:{result.Reason}");
|
||||
}
|
||||
|
||||
logger.LogInformation("AirGap time validation passed: anchor={Anchor} age={Age}s tenant={Tenant}", result.Status?.Anchor.TokenDigest, result.Status?.Staleness.AgeSeconds, tenantId);
|
||||
return host;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ builder.Services.AddSingleton<TimeAnchorLoader>();
|
||||
builder.Services.AddSingleton<TimeTokenParser>();
|
||||
builder.Services.AddSingleton<SealedStartupValidator>();
|
||||
builder.Services.AddSingleton<TrustRootProvider>();
|
||||
builder.Services.AddHostedService<SealedStartupHostedService>();
|
||||
|
||||
// AIRGAP-TIME-57-001: Time-anchor policy service
|
||||
builder.Services.Configure<TimeAnchorPolicyOptions>(builder.Configuration.GetSection("AirGap:Policy"));
|
||||
@@ -44,13 +45,4 @@ app.LogStellaOpsLocalHostname("airgap-time");
|
||||
app.UseStellaOpsCors();
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/healthz/ready");
|
||||
|
||||
var opts = app.Services.GetRequiredService<IOptions<AirGapOptions>>().Value;
|
||||
var tenantId = opts.TenantId;
|
||||
var budget = new StalenessBudget(opts.Staleness.WarningSeconds, opts.Staleness.BreachSeconds);
|
||||
|
||||
app.Services.GetRequiredService<ILogger<Program>>()
|
||||
.LogInformation("AirGap Time starting for tenant {Tenant} with budgets warning={Warning}s breach={Breach}s", tenantId, budget.WarningSeconds, budget.BreachSeconds);
|
||||
|
||||
app.ValidateTimeAnchorOnStart(tenantId, budget);
|
||||
app.Run();
|
||||
|
||||
29
src/AirGap/StellaOps.AirGap.Time/Services/Ed25519.cs
Normal file
29
src/AirGap/StellaOps.AirGap.Time/Services/Ed25519.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
internal static class Ed25519
|
||||
{
|
||||
public static bool Verify(byte[] publicKey, byte[] message, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.CreateFromValue("1.3.101.112"));
|
||||
ecdsa.ImportSubjectPublicKeyInfo(CreateEd25519Spki(publicKey), out _);
|
||||
return ecdsa.VerifyData(message, signature, HashAlgorithmName.SHA512);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateEd25519Spki(byte[] publicKey)
|
||||
{
|
||||
var spki = new byte[44];
|
||||
new byte[] { 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00 }
|
||||
.CopyTo(spki, 0);
|
||||
publicKey.CopyTo(spki, 12);
|
||||
return spki;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Policy enforcement service for time anchors.
|
||||
/// </summary>
|
||||
public interface ITimeAnchorPolicyService
|
||||
{
|
||||
Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
DateTimeOffset? bundleTimestamp,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
|
||||
string tenantId,
|
||||
string operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<TimeAnchorDriftResult> CalculateDriftAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset targetTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class Rfc3161Verifier
|
||||
{
|
||||
private static readonly Oid _tstInfoOid = new("1.2.840.113549.1.9.16.1.4");
|
||||
private static readonly Oid _signingTimeOid = new("1.2.840.113549.1.9.5");
|
||||
|
||||
private static DateTimeOffset? ExtractSigningTime(SignedCms signedCms, SignerInfo signerInfo)
|
||||
{
|
||||
foreach (var attr in signerInfo.SignedAttributes)
|
||||
{
|
||||
if (attr.Oid.Value == _signingTimeOid.Value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(attr.Values[0].RawData, AsnEncodingRules.DER);
|
||||
return reader.ReadUtcTime();
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var content = signedCms.ContentInfo;
|
||||
if (content.ContentType.Value == _tstInfoOid.Value)
|
||||
{
|
||||
var tstInfo = ParseTstInfo(content.Content);
|
||||
if (tstInfo.HasValue)
|
||||
{
|
||||
return tstInfo.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTstInfo(ReadOnlyMemory<byte> tstInfoBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(tstInfoBytes, AsnEncodingRules.DER);
|
||||
var sequenceReader = reader.ReadSequence();
|
||||
|
||||
sequenceReader.ReadInteger();
|
||||
sequenceReader.ReadObjectIdentifier();
|
||||
sequenceReader.ReadSequence();
|
||||
sequenceReader.ReadInteger();
|
||||
|
||||
return sequenceReader.ReadGeneralizedTime();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Formats.Asn1;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class Rfc3161Verifier
|
||||
{
|
||||
private static bool TryVerifyOfflineRevocation(
|
||||
TimeTokenVerificationOptions options,
|
||||
out string reason)
|
||||
{
|
||||
var hasOcsp = options.OcspResponses.Count > 0;
|
||||
var hasCrl = options.Crls.Count > 0;
|
||||
|
||||
if (!hasOcsp && !hasCrl)
|
||||
{
|
||||
reason = "rfc3161-revocation-missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasOcsp && options.OcspResponses.Any(IsOcspSuccess))
|
||||
{
|
||||
reason = "rfc3161-revocation-ocsp";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasCrl && options.Crls.Any(IsCrlParseable))
|
||||
{
|
||||
reason = "rfc3161-revocation-crl";
|
||||
return true;
|
||||
}
|
||||
|
||||
reason = "rfc3161-revocation-invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsOcspSuccess(byte[] response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(response, AsnEncodingRules.DER);
|
||||
var sequence = reader.ReadSequence();
|
||||
var status = sequence.ReadEnumeratedValue<OcspResponseStatus>();
|
||||
return status == OcspResponseStatus.Successful;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCrlParseable(byte[] crl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(crl, AsnEncodingRules.DER);
|
||||
reader.ReadSequence();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private enum OcspResponseStatus
|
||||
{
|
||||
Successful = 0,
|
||||
MalformedRequest = 1,
|
||||
InternalError = 2,
|
||||
TryLater = 3,
|
||||
SigRequired = 5,
|
||||
Unauthorized = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class Rfc3161Verifier
|
||||
{
|
||||
private static TimeTrustRoot? ValidateAgainstTrustRoots(
|
||||
X509Certificate2 signerCert,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
IReadOnlyList<X509Certificate2> extraCertificates,
|
||||
DateTimeOffset verificationTime)
|
||||
{
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rootCert = X509CertificateLoader.LoadCertificate(root.PublicKey);
|
||||
if (signerCert.Thumbprint.Equals(rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCert);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
chain.ChainPolicy.VerificationTime = verificationTime.UtcDateTime;
|
||||
|
||||
foreach (var cert in extraCertificates)
|
||||
{
|
||||
if (!string.Equals(cert.Thumbprint, rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.Build(signerCert))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<X509Certificate2> BuildExtraCertificates(
|
||||
SignedCms signedCms,
|
||||
TimeTokenVerificationOptions? options)
|
||||
{
|
||||
var extra = new List<X509Certificate2>();
|
||||
if (options?.CertificateChain is { Count: > 0 })
|
||||
{
|
||||
extra.AddRange(options.CertificateChain);
|
||||
}
|
||||
|
||||
foreach (var cert in signedCms.Certificates.Cast<X509Certificate2>())
|
||||
{
|
||||
if (!extra.Any(existing => existing.Thumbprint.Equals(cert.Thumbprint, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
extra.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
return extra;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class Rfc3161Verifier
|
||||
{
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-token-empty");
|
||||
}
|
||||
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
var signedCms = new SignedCms();
|
||||
signedCms.Decode(tokenBytes.ToArray());
|
||||
|
||||
try
|
||||
{
|
||||
signedCms.CheckSignature(verifySignatureOnly: true);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-signature-invalid:{ex.Message}");
|
||||
}
|
||||
|
||||
if (signedCms.SignerInfos.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer");
|
||||
}
|
||||
|
||||
var signerInfo = signedCms.SignerInfos[0];
|
||||
var signerCert = signerInfo.Certificate;
|
||||
if (signerCert is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer-certificate");
|
||||
}
|
||||
|
||||
var signingTime = ExtractSigningTime(signedCms, signerInfo);
|
||||
if (signingTime is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signing-time");
|
||||
}
|
||||
|
||||
var extraCertificates = BuildExtraCertificates(signedCms, options);
|
||||
var verificationTime = options?.VerificationTime ?? signingTime.Value;
|
||||
var validRoot = ValidateAgainstTrustRoots(
|
||||
signerCert,
|
||||
trustRoots,
|
||||
extraCertificates,
|
||||
verificationTime);
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-certificate-not-trusted");
|
||||
}
|
||||
|
||||
if (options?.Offline == true)
|
||||
{
|
||||
if (!TryVerifyOfflineRevocation(options, out var revocationReason))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure(revocationReason);
|
||||
}
|
||||
}
|
||||
|
||||
var certFingerprint = Convert.ToHexString(SHA256.HashData(signerCert.RawData)).ToLowerInvariant()[..16];
|
||||
|
||||
anchor = new TimeAnchor(
|
||||
signingTime.Value,
|
||||
$"rfc3161:{validRoot.KeyId}",
|
||||
"RFC3161",
|
||||
certFingerprint,
|
||||
tokenDigest);
|
||||
|
||||
return TimeAnchorValidationResult.Success("rfc3161-verified");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-decode-error:{ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-error:{ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
@@ -12,329 +6,7 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
/// Verifies RFC 3161 timestamp tokens using SignedCms and X509 certificate chain validation.
|
||||
/// Per AIRGAP-TIME-57-001: Provides trusted time-anchor service with real crypto verification.
|
||||
/// </summary>
|
||||
public sealed class Rfc3161Verifier : ITimeTokenVerifier
|
||||
public sealed partial class Rfc3161Verifier : ITimeTokenVerifier
|
||||
{
|
||||
// RFC 3161 OIDs
|
||||
private static readonly Oid TstInfoOid = new("1.2.840.113549.1.9.16.1.4"); // id-ct-TSTInfo
|
||||
private static readonly Oid SigningTimeOid = new("1.2.840.113549.1.9.5");
|
||||
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Rfc3161;
|
||||
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-token-empty");
|
||||
}
|
||||
|
||||
// Compute token digest for reference
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
// Parse the SignedCms structure
|
||||
var signedCms = new SignedCms();
|
||||
signedCms.Decode(tokenBytes.ToArray());
|
||||
|
||||
// Verify signature (basic check without chain building)
|
||||
try
|
||||
{
|
||||
signedCms.CheckSignature(verifySignatureOnly: true);
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-signature-invalid:{ex.Message}");
|
||||
}
|
||||
|
||||
// Extract the signing certificate
|
||||
if (signedCms.SignerInfos.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer");
|
||||
}
|
||||
|
||||
var signerInfo = signedCms.SignerInfos[0];
|
||||
var signerCert = signerInfo.Certificate;
|
||||
|
||||
if (signerCert is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signer-certificate");
|
||||
}
|
||||
|
||||
// Extract signing time from the TSTInfo or signed attributes
|
||||
var signingTime = ExtractSigningTime(signedCms, signerInfo);
|
||||
if (signingTime is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-no-signing-time");
|
||||
}
|
||||
|
||||
// Validate signer certificate against trust roots
|
||||
var extraCertificates = BuildExtraCertificates(signedCms, options);
|
||||
var verificationTime = options?.VerificationTime ?? signingTime.Value;
|
||||
var validRoot = ValidateAgainstTrustRoots(
|
||||
signerCert,
|
||||
trustRoots,
|
||||
extraCertificates,
|
||||
verificationTime);
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("rfc3161-certificate-not-trusted");
|
||||
}
|
||||
|
||||
if (options?.Offline == true)
|
||||
{
|
||||
if (!TryVerifyOfflineRevocation(options, out var revocationReason))
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure(revocationReason);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute certificate fingerprint
|
||||
var certFingerprint = Convert.ToHexString(SHA256.HashData(signerCert.RawData)).ToLowerInvariant()[..16];
|
||||
|
||||
anchor = new TimeAnchor(
|
||||
signingTime.Value,
|
||||
$"rfc3161:{validRoot.KeyId}",
|
||||
"RFC3161",
|
||||
certFingerprint,
|
||||
tokenDigest);
|
||||
|
||||
return TimeAnchorValidationResult.Success("rfc3161-verified");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-decode-error:{ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure($"rfc3161-error:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeTrustRoot? ValidateAgainstTrustRoots(
|
||||
X509Certificate2 signerCert,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
IReadOnlyList<X509Certificate2> extraCertificates,
|
||||
DateTimeOffset verificationTime)
|
||||
{
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
// Match by certificate thumbprint or subject key identifier
|
||||
try
|
||||
{
|
||||
// Try direct certificate match
|
||||
var rootCert = X509CertificateLoader.LoadCertificate(root.PublicKey);
|
||||
if (signerCert.Thumbprint.Equals(rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
|
||||
// Try chain validation against root
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(rootCert);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Offline mode
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
chain.ChainPolicy.VerificationTime = verificationTime.UtcDateTime;
|
||||
|
||||
foreach (var cert in extraCertificates)
|
||||
{
|
||||
if (!string.Equals(cert.Thumbprint, rootCert.Thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
if (chain.Build(signerCert))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Invalid root certificate format, try next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<X509Certificate2> BuildExtraCertificates(
|
||||
SignedCms signedCms,
|
||||
TimeTokenVerificationOptions? options)
|
||||
{
|
||||
var extra = new List<X509Certificate2>();
|
||||
if (options?.CertificateChain is { Count: > 0 })
|
||||
{
|
||||
extra.AddRange(options.CertificateChain);
|
||||
}
|
||||
|
||||
foreach (var cert in signedCms.Certificates.Cast<X509Certificate2>())
|
||||
{
|
||||
if (!extra.Any(existing =>
|
||||
existing.Thumbprint.Equals(cert.Thumbprint, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
extra.Add(cert);
|
||||
}
|
||||
}
|
||||
|
||||
return extra;
|
||||
}
|
||||
|
||||
private static bool TryVerifyOfflineRevocation(
|
||||
TimeTokenVerificationOptions options,
|
||||
out string reason)
|
||||
{
|
||||
var hasOcsp = options.OcspResponses.Count > 0;
|
||||
var hasCrl = options.Crls.Count > 0;
|
||||
|
||||
if (!hasOcsp && !hasCrl)
|
||||
{
|
||||
reason = "rfc3161-revocation-missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasOcsp && options.OcspResponses.Any(IsOcspSuccess))
|
||||
{
|
||||
reason = "rfc3161-revocation-ocsp";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasCrl && options.Crls.Any(IsCrlParseable))
|
||||
{
|
||||
reason = "rfc3161-revocation-crl";
|
||||
return true;
|
||||
}
|
||||
|
||||
reason = "rfc3161-revocation-invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsOcspSuccess(byte[] response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(response, AsnEncodingRules.DER);
|
||||
var sequence = reader.ReadSequence();
|
||||
var status = sequence.ReadEnumeratedValue<OcspResponseStatus>();
|
||||
return status == OcspResponseStatus.Successful;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCrlParseable(byte[] crl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(crl, AsnEncodingRules.DER);
|
||||
reader.ReadSequence();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ExtractSigningTime(SignedCms signedCms, SignerInfo signerInfo)
|
||||
{
|
||||
// Try to get signing time from signed attributes
|
||||
foreach (var attr in signerInfo.SignedAttributes)
|
||||
{
|
||||
if (attr.Oid.Value == SigningTimeOid.Value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(attr.Values[0].RawData, AsnEncodingRules.DER);
|
||||
var time = reader.ReadUtcTime();
|
||||
return time;
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from TSTInfo content
|
||||
try
|
||||
{
|
||||
var content = signedCms.ContentInfo;
|
||||
if (content.ContentType.Value == TstInfoOid.Value)
|
||||
{
|
||||
var tstInfo = ParseTstInfo(content.Content);
|
||||
if (tstInfo.HasValue)
|
||||
{
|
||||
return tstInfo.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseTstInfo(ReadOnlyMemory<byte> tstInfoBytes)
|
||||
{
|
||||
// TSTInfo ::= SEQUENCE {
|
||||
// version INTEGER,
|
||||
// policy OBJECT IDENTIFIER,
|
||||
// messageImprint MessageImprint,
|
||||
// serialNumber INTEGER,
|
||||
// genTime GeneralizedTime,
|
||||
// ...
|
||||
// }
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(tstInfoBytes, AsnEncodingRules.DER);
|
||||
var sequenceReader = reader.ReadSequence();
|
||||
|
||||
// Skip version
|
||||
sequenceReader.ReadInteger();
|
||||
|
||||
// Skip policy OID
|
||||
sequenceReader.ReadObjectIdentifier();
|
||||
|
||||
// Skip messageImprint (SEQUENCE)
|
||||
sequenceReader.ReadSequence();
|
||||
|
||||
// Skip serialNumber
|
||||
sequenceReader.ReadInteger();
|
||||
|
||||
// Read genTime (GeneralizedTime)
|
||||
var genTime = sequenceReader.ReadGeneralizedTime();
|
||||
return genTime;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private enum OcspResponseStatus
|
||||
{
|
||||
Successful = 0,
|
||||
MalformedRequest = 1,
|
||||
InternalError = 2,
|
||||
TryLater = 3,
|
||||
SigRequired = 5,
|
||||
Unauthorized = 6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
private static TimeAnchorValidationResult ParseRoughtimeResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> signature,
|
||||
out ReadOnlySpan<byte> signedMessage)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
signature = ReadOnlySpan<byte>.Empty;
|
||||
signedMessage = ReadOnlySpan<byte>.Empty;
|
||||
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-message-too-short");
|
||||
}
|
||||
|
||||
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
if (numTags == 0 || numTags > 100)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-tag-count");
|
||||
}
|
||||
|
||||
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
|
||||
if (data.Length < headerSize)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-header-incomplete");
|
||||
}
|
||||
|
||||
var offsetsStart = 4;
|
||||
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
|
||||
var valuesStart = headerSize;
|
||||
|
||||
ReadOnlySpan<byte> sigBytes = ReadOnlySpan<byte>.Empty;
|
||||
ReadOnlySpan<byte> srepBytes = ReadOnlySpan<byte>.Empty;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
|
||||
var valueStart = valuesStart;
|
||||
var valueEnd = data.Length;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
data.Slice(offsetsStart + ((i - 1) * 4)));
|
||||
}
|
||||
|
||||
if (i < (int)numTags - 1)
|
||||
{
|
||||
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
data.Slice(offsetsStart + (i * 4)));
|
||||
}
|
||||
|
||||
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-value-bounds");
|
||||
}
|
||||
|
||||
var value = data.Slice(valueStart, valueEnd - valueStart);
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case TagSig:
|
||||
if (value.Length != Ed25519SignatureLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-signature-length");
|
||||
}
|
||||
sigBytes = value;
|
||||
break;
|
||||
case TagSrep:
|
||||
srepBytes = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sigBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-signature");
|
||||
}
|
||||
|
||||
if (srepBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-srep");
|
||||
}
|
||||
|
||||
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros, out _, out _, out _);
|
||||
if (!srepResult.IsValid)
|
||||
{
|
||||
return srepResult;
|
||||
}
|
||||
|
||||
signature = sigBytes;
|
||||
signedMessage = srepBytes;
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-parsed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
private static TimeAnchorValidationResult ReadSignedResponseTags(
|
||||
ReadOnlySpan<byte> data,
|
||||
uint numTags,
|
||||
int offsetsStart,
|
||||
int tagsStart,
|
||||
int valuesStart,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> rootBytes,
|
||||
out ReadOnlySpan<byte> pathBytes,
|
||||
out uint index,
|
||||
out bool hasMidp,
|
||||
out bool hasRadi,
|
||||
out bool hasRoot,
|
||||
out bool hasPath,
|
||||
out bool hasIndex)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
rootBytes = ReadOnlySpan<byte>.Empty;
|
||||
pathBytes = ReadOnlySpan<byte>.Empty;
|
||||
index = 0;
|
||||
hasMidp = false;
|
||||
hasRadi = false;
|
||||
hasRoot = false;
|
||||
hasPath = false;
|
||||
hasIndex = false;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
|
||||
var valueStart = valuesStart;
|
||||
var valueEnd = data.Length;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
data.Slice(offsetsStart + ((i - 1) * 4)));
|
||||
}
|
||||
|
||||
if (i < (int)numTags - 1)
|
||||
{
|
||||
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
data.Slice(offsetsStart + (i * 4)));
|
||||
}
|
||||
|
||||
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = data.Slice(valueStart, valueEnd - valueStart);
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case TagMidp:
|
||||
if (value.Length == 8)
|
||||
{
|
||||
midpointMicros = BinaryPrimitives.ReadInt64LittleEndian(value);
|
||||
hasMidp = true;
|
||||
}
|
||||
break;
|
||||
case TagRadi:
|
||||
if (value.Length == 4)
|
||||
{
|
||||
radiusMicros = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasRadi = true;
|
||||
}
|
||||
break;
|
||||
case TagRoot:
|
||||
if (value.Length == MerkleNodeLength)
|
||||
{
|
||||
rootBytes = value;
|
||||
hasRoot = true;
|
||||
}
|
||||
break;
|
||||
case TagPath:
|
||||
if (!value.IsEmpty && value.Length % MerkleNodeLength == 0)
|
||||
{
|
||||
pathBytes = value;
|
||||
hasPath = true;
|
||||
}
|
||||
break;
|
||||
case TagIndx:
|
||||
if (value.Length == MerkleIndexLength)
|
||||
{
|
||||
index = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasIndex = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-srep-tags-read");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
private static TimeAnchorValidationResult ParseSignedResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> rootBytes,
|
||||
out ReadOnlySpan<byte> pathBytes,
|
||||
out uint index)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
rootBytes = ReadOnlySpan<byte>.Empty;
|
||||
pathBytes = ReadOnlySpan<byte>.Empty;
|
||||
index = 0;
|
||||
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-too-short");
|
||||
}
|
||||
|
||||
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
if (numTags == 0 || numTags > 50)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-invalid-tag-count");
|
||||
}
|
||||
|
||||
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
|
||||
if (data.Length < headerSize)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-header-incomplete");
|
||||
}
|
||||
|
||||
var offsetsStart = 4;
|
||||
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
|
||||
var valuesStart = headerSize;
|
||||
|
||||
var readResult = ReadSignedResponseTags(
|
||||
data,
|
||||
numTags,
|
||||
offsetsStart,
|
||||
tagsStart,
|
||||
valuesStart,
|
||||
out midpointMicros,
|
||||
out radiusMicros,
|
||||
out rootBytes,
|
||||
out pathBytes,
|
||||
out index,
|
||||
out var hasMidp,
|
||||
out var hasRadi,
|
||||
out var hasRoot,
|
||||
out var hasPath,
|
||||
out var hasIndex);
|
||||
if (!readResult.IsValid)
|
||||
{
|
||||
return readResult;
|
||||
}
|
||||
|
||||
if (!hasMidp)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-midpoint");
|
||||
}
|
||||
|
||||
if (!hasRoot)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-root");
|
||||
}
|
||||
|
||||
if (!hasPath)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-path");
|
||||
}
|
||||
|
||||
if (!hasIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-index");
|
||||
}
|
||||
|
||||
var pathValidation = ValidateMerklePathStructure(rootBytes, pathBytes, index);
|
||||
if (!pathValidation.IsValid)
|
||||
{
|
||||
return pathValidation;
|
||||
}
|
||||
|
||||
if (!hasRadi)
|
||||
{
|
||||
radiusMicros = 1_000_000;
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-srep-parsed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
private static TimeAnchorValidationResult ValidateMerklePathStructure(
|
||||
ReadOnlySpan<byte> rootBytes,
|
||||
ReadOnlySpan<byte> pathBytes,
|
||||
uint index)
|
||||
{
|
||||
if (rootBytes.Length != MerkleNodeLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-root-length");
|
||||
}
|
||||
|
||||
if (pathBytes.IsEmpty || pathBytes.Length % MerkleNodeLength != 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-path-length");
|
||||
}
|
||||
|
||||
var depth = pathBytes.Length / MerkleNodeLength;
|
||||
if (depth <= 31)
|
||||
{
|
||||
var maxIndex = 1u << depth;
|
||||
if (index >= maxIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-index");
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-merkle-structure-valid");
|
||||
}
|
||||
|
||||
private static bool VerifyEd25519Signature(ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature, byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
const string ContextPrefix = "RoughTime v1 response signature\0";
|
||||
var prefixBytes = System.Text.Encoding.ASCII.GetBytes(ContextPrefix);
|
||||
var signedData = new byte[prefixBytes.Length + message.Length];
|
||||
prefixBytes.CopyTo(signedData, 0);
|
||||
message.CopyTo(signedData.AsSpan(prefixBytes.Length));
|
||||
|
||||
return Ed25519.Verify(publicKey, signedData, signature.ToArray());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class RoughtimeVerifier
|
||||
{
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-token-empty");
|
||||
}
|
||||
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
var parseResult = ParseRoughtimeResponse(
|
||||
tokenBytes,
|
||||
out var midpointMicros,
|
||||
out var radiusMicros,
|
||||
out var signature,
|
||||
out var signedMessage);
|
||||
if (!parseResult.IsValid)
|
||||
{
|
||||
return parseResult;
|
||||
}
|
||||
|
||||
TimeTrustRoot? validRoot = null;
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
if (!string.Equals(root.Algorithm, "ed25519", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root.PublicKey.Length != Ed25519PublicKeyLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VerifyEd25519Signature(signedMessage, signature, root.PublicKey))
|
||||
{
|
||||
validRoot = root;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-signature-invalid");
|
||||
}
|
||||
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddMicroseconds(midpointMicros);
|
||||
var keyFingerprint = Convert.ToHexString(SHA256.HashData(validRoot.PublicKey)).ToLowerInvariant()[..16];
|
||||
|
||||
anchor = new TimeAnchor(
|
||||
anchorTime,
|
||||
$"roughtime:{validRoot.KeyId}",
|
||||
"Roughtime",
|
||||
keyFingerprint,
|
||||
tokenDigest);
|
||||
|
||||
return TimeAnchorValidationResult.Success($"roughtime-verified:radius={radiusMicros}us");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
@@ -10,422 +6,20 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
/// Verifies Roughtime tokens using Ed25519 signature verification.
|
||||
/// Per AIRGAP-TIME-57-001: Provides trusted time-anchor service with real crypto verification.
|
||||
/// </summary>
|
||||
public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
public sealed partial class RoughtimeVerifier : ITimeTokenVerifier
|
||||
{
|
||||
// Roughtime wire format tag constants (32-bit little-endian ASCII codes)
|
||||
private const uint TagSig = 0x00474953; // "SIG\0" - Signature
|
||||
private const uint TagMidp = 0x5044494D; // "MIDP" - Midpoint
|
||||
private const uint TagRadi = 0x49444152; // "RADI" - Radius
|
||||
private const uint TagRoot = 0x544F4F52; // "ROOT" - Merkle root
|
||||
private const uint TagPath = 0x48544150; // "PATH" - Merkle path
|
||||
private const uint TagIndx = 0x58444E49; // "INDX" - Index
|
||||
private const uint TagSrep = 0x50455253; // "SREP" - Signed response
|
||||
private const uint TagSig = 0x00474953;
|
||||
private const uint TagMidp = 0x5044494D;
|
||||
private const uint TagRadi = 0x49444152;
|
||||
private const uint TagRoot = 0x544F4F52;
|
||||
private const uint TagPath = 0x48544150;
|
||||
private const uint TagIndx = 0x58444E49;
|
||||
private const uint TagSrep = 0x50455253;
|
||||
|
||||
// Ed25519 constants
|
||||
private const int Ed25519SignatureLength = 64;
|
||||
private const int Ed25519PublicKeyLength = 32;
|
||||
private const int MerkleNodeLength = 32;
|
||||
private const int MerkleIndexLength = 4;
|
||||
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Roughtime;
|
||||
|
||||
public TimeAnchorValidationResult Verify(
|
||||
ReadOnlySpan<byte> tokenBytes,
|
||||
IReadOnlyList<TimeTrustRoot> trustRoots,
|
||||
out TimeAnchor anchor,
|
||||
TimeTokenVerificationOptions? options = null)
|
||||
{
|
||||
anchor = TimeAnchor.Unknown;
|
||||
|
||||
if (trustRoots.Count == 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-trust-roots-required");
|
||||
}
|
||||
|
||||
if (tokenBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-token-empty");
|
||||
}
|
||||
|
||||
// Compute token digest for reference
|
||||
var tokenDigest = Convert.ToHexString(SHA256.HashData(tokenBytes)).ToLowerInvariant();
|
||||
|
||||
// Parse Roughtime wire format
|
||||
var parseResult = ParseRoughtimeResponse(tokenBytes, out var midpointMicros, out var radiusMicros, out var signature, out var signedMessage);
|
||||
|
||||
if (!parseResult.IsValid)
|
||||
{
|
||||
return parseResult;
|
||||
}
|
||||
|
||||
// Find a valid trust root with Ed25519 key
|
||||
TimeTrustRoot? validRoot = null;
|
||||
foreach (var root in trustRoots)
|
||||
{
|
||||
if (!string.Equals(root.Algorithm, "ed25519", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root.PublicKey.Length != Ed25519PublicKeyLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify Ed25519 signature
|
||||
if (VerifyEd25519Signature(signedMessage, signature, root.PublicKey))
|
||||
{
|
||||
validRoot = root;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (validRoot is null)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-signature-invalid");
|
||||
}
|
||||
|
||||
// Convert midpoint from microseconds to DateTimeOffset
|
||||
var anchorTime = DateTimeOffset.UnixEpoch.AddMicroseconds(midpointMicros);
|
||||
|
||||
// Compute signature fingerprint from the public key
|
||||
var keyFingerprint = Convert.ToHexString(SHA256.HashData(validRoot.PublicKey)).ToLowerInvariant()[..16];
|
||||
|
||||
anchor = new TimeAnchor(
|
||||
anchorTime,
|
||||
$"roughtime:{validRoot.KeyId}",
|
||||
"Roughtime",
|
||||
keyFingerprint,
|
||||
tokenDigest);
|
||||
|
||||
return TimeAnchorValidationResult.Success($"roughtime-verified:radius={radiusMicros}us");
|
||||
}
|
||||
|
||||
private static TimeAnchorValidationResult ParseRoughtimeResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> signature,
|
||||
out ReadOnlySpan<byte> signedMessage)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
signature = ReadOnlySpan<byte>.Empty;
|
||||
signedMessage = ReadOnlySpan<byte>.Empty;
|
||||
|
||||
// Roughtime wire format: [num_tags:u32] [offsets:u32[]] [tags:u32[]] [values...]
|
||||
// Minimum size: 4 (num_tags) + at least one tag
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-message-too-short");
|
||||
}
|
||||
|
||||
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
|
||||
if (numTags == 0 || numTags > 100)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-tag-count");
|
||||
}
|
||||
|
||||
// Header size: 4 + 4*(numTags-1) offsets + 4*numTags tags
|
||||
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
|
||||
|
||||
if (data.Length < headerSize)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-header-incomplete");
|
||||
}
|
||||
|
||||
// Parse tags and extract required fields
|
||||
var offsetsStart = 4;
|
||||
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
|
||||
var valuesStart = headerSize;
|
||||
|
||||
ReadOnlySpan<byte> sigBytes = ReadOnlySpan<byte>.Empty;
|
||||
ReadOnlySpan<byte> srepBytes = ReadOnlySpan<byte>.Empty;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
|
||||
|
||||
// Calculate value bounds
|
||||
var valueStart = valuesStart;
|
||||
var valueEnd = data.Length;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + ((i - 1) * 4)));
|
||||
}
|
||||
|
||||
if (i < (int)numTags - 1)
|
||||
{
|
||||
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + (i * 4)));
|
||||
}
|
||||
|
||||
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-value-bounds");
|
||||
}
|
||||
|
||||
var value = data.Slice(valueStart, valueEnd - valueStart);
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case TagSig:
|
||||
if (value.Length != Ed25519SignatureLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-signature-length");
|
||||
}
|
||||
sigBytes = value;
|
||||
break;
|
||||
case TagSrep:
|
||||
srepBytes = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sigBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-signature");
|
||||
}
|
||||
|
||||
if (srepBytes.IsEmpty)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-srep");
|
||||
}
|
||||
|
||||
// Parse SREP (signed response) for MIDP and RADI
|
||||
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros, out _, out _, out _);
|
||||
if (!srepResult.IsValid)
|
||||
{
|
||||
return srepResult;
|
||||
}
|
||||
|
||||
signature = sigBytes;
|
||||
signedMessage = srepBytes;
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-parsed");
|
||||
}
|
||||
|
||||
private static TimeAnchorValidationResult ParseSignedResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> rootBytes,
|
||||
out ReadOnlySpan<byte> pathBytes,
|
||||
out uint index)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
rootBytes = ReadOnlySpan<byte>.Empty;
|
||||
pathBytes = ReadOnlySpan<byte>.Empty;
|
||||
index = 0;
|
||||
|
||||
if (data.Length < 8)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-too-short");
|
||||
}
|
||||
|
||||
var numTags = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
||||
|
||||
if (numTags == 0 || numTags > 50)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-invalid-tag-count");
|
||||
}
|
||||
|
||||
var headerSize = 4 + (4 * ((int)numTags - 1)) + (4 * (int)numTags);
|
||||
|
||||
if (data.Length < headerSize)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-srep-header-incomplete");
|
||||
}
|
||||
|
||||
var offsetsStart = 4;
|
||||
var tagsStart = offsetsStart + (4 * ((int)numTags - 1));
|
||||
var valuesStart = headerSize;
|
||||
|
||||
var hasMidp = false;
|
||||
var hasRadi = false;
|
||||
var hasRoot = false;
|
||||
var hasPath = false;
|
||||
var hasIndex = false;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
var tag = BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(tagsStart + (i * 4)));
|
||||
|
||||
var valueStart = valuesStart;
|
||||
var valueEnd = data.Length;
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
valueStart = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + ((i - 1) * 4)));
|
||||
}
|
||||
|
||||
if (i < (int)numTags - 1)
|
||||
{
|
||||
valueEnd = valuesStart + (int)BinaryPrimitives.ReadUInt32LittleEndian(data.Slice(offsetsStart + (i * 4)));
|
||||
}
|
||||
|
||||
if (valueStart < 0 || valueEnd > data.Length || valueStart > valueEnd)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = data.Slice(valueStart, valueEnd - valueStart);
|
||||
|
||||
switch (tag)
|
||||
{
|
||||
case TagMidp:
|
||||
if (value.Length == 8)
|
||||
{
|
||||
midpointMicros = BinaryPrimitives.ReadInt64LittleEndian(value);
|
||||
hasMidp = true;
|
||||
}
|
||||
break;
|
||||
case TagRadi:
|
||||
if (value.Length == 4)
|
||||
{
|
||||
radiusMicros = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasRadi = true;
|
||||
}
|
||||
break;
|
||||
case TagRoot:
|
||||
if (value.Length == MerkleNodeLength)
|
||||
{
|
||||
rootBytes = value;
|
||||
hasRoot = true;
|
||||
}
|
||||
break;
|
||||
case TagPath:
|
||||
if (!value.IsEmpty && value.Length % MerkleNodeLength == 0)
|
||||
{
|
||||
pathBytes = value;
|
||||
hasPath = true;
|
||||
}
|
||||
break;
|
||||
case TagIndx:
|
||||
if (value.Length == MerkleIndexLength)
|
||||
{
|
||||
index = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasIndex = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMidp)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-midpoint");
|
||||
}
|
||||
|
||||
if (!hasRoot)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-root");
|
||||
}
|
||||
|
||||
if (!hasPath)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-path");
|
||||
}
|
||||
|
||||
if (!hasIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-index");
|
||||
}
|
||||
|
||||
var pathValidation = ValidateMerklePathStructure(rootBytes, pathBytes, index);
|
||||
if (!pathValidation.IsValid)
|
||||
{
|
||||
return pathValidation;
|
||||
}
|
||||
|
||||
if (!hasRadi)
|
||||
{
|
||||
// RADI is optional, default to 1 second uncertainty
|
||||
radiusMicros = 1_000_000;
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-srep-parsed");
|
||||
}
|
||||
|
||||
private static TimeAnchorValidationResult ValidateMerklePathStructure(ReadOnlySpan<byte> rootBytes, ReadOnlySpan<byte> pathBytes, uint index)
|
||||
{
|
||||
if (rootBytes.Length != MerkleNodeLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-root-length");
|
||||
}
|
||||
|
||||
if (pathBytes.IsEmpty || pathBytes.Length % MerkleNodeLength != 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-path-length");
|
||||
}
|
||||
|
||||
var depth = pathBytes.Length / MerkleNodeLength;
|
||||
if (depth <= 31)
|
||||
{
|
||||
var maxIndex = 1u << depth;
|
||||
if (index >= maxIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-index");
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-merkle-structure-valid");
|
||||
}
|
||||
|
||||
private static bool VerifyEd25519Signature(ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature, byte[] publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Roughtime signs the context-prefixed message: "RoughTime v1 response signature\0" || SREP
|
||||
const string ContextPrefix = "RoughTime v1 response signature\0";
|
||||
var prefixBytes = System.Text.Encoding.ASCII.GetBytes(ContextPrefix);
|
||||
var signedData = new byte[prefixBytes.Length + message.Length];
|
||||
prefixBytes.CopyTo(signedData, 0);
|
||||
message.CopyTo(signedData.AsSpan(prefixBytes.Length));
|
||||
|
||||
// Use .NET's Ed25519 verification
|
||||
// Note: .NET 10 supports Ed25519 natively via ECDsa with curve Ed25519
|
||||
return Ed25519.Verify(publicKey, signedData, signature.ToArray());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ed25519 signature verification helper using .NET cryptography.
|
||||
/// </summary>
|
||||
internal static class Ed25519
|
||||
{
|
||||
public static bool Verify(byte[] publicKey, byte[] message, byte[] signature)
|
||||
{
|
||||
try
|
||||
{
|
||||
// .NET 10 has native Ed25519 support via ECDsa
|
||||
using var ecdsa = ECDsa.Create(ECCurve.CreateFromValue("1.3.101.112")); // Ed25519 OID
|
||||
ecdsa.ImportSubjectPublicKeyInfo(CreateEd25519Spki(publicKey), out _);
|
||||
return ecdsa.VerifyData(message, signature, HashAlgorithmName.SHA512);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback: if Ed25519 curve not available, return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateEd25519Spki(byte[] publicKey)
|
||||
{
|
||||
// Ed25519 SPKI format:
|
||||
// 30 2a - SEQUENCE (42 bytes)
|
||||
// 30 05 - SEQUENCE (5 bytes)
|
||||
// 06 03 2b 65 70 - OID 1.3.101.112 (Ed25519)
|
||||
// 03 21 00 [32 bytes public key]
|
||||
var spki = new byte[44];
|
||||
new byte[] { 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00 }.CopyTo(spki, 0);
|
||||
publicKey.CopyTo(spki, 12);
|
||||
return spki;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class SealedStartupValidator
|
||||
|
||||
public async Task<StartupValidationResult> ValidateAsync(string tenantId, StalenessBudget budget, CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), cancellationToken);
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of time drift calculation.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorDriftResult(
|
||||
bool HasAnchor,
|
||||
TimeSpan Drift,
|
||||
bool DriftExceedsThreshold,
|
||||
DateTimeOffset? AnchorTime);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for time-anchor policy violations.
|
||||
/// </summary>
|
||||
public static class TimeAnchorPolicyErrorCodes
|
||||
{
|
||||
public const string AnchorMissing = "TIME_ANCHOR_MISSING";
|
||||
public const string AnchorStale = "TIME_ANCHOR_STALE";
|
||||
public const string AnchorBreached = "TIME_ANCHOR_BREACHED";
|
||||
public const string DriftExceeded = "TIME_ANCHOR_DRIFT_EXCEEDED";
|
||||
public const string PolicyViolation = "TIME_ANCHOR_POLICY_VIOLATION";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Policy configuration for time anchors.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorPolicyOptions
|
||||
{
|
||||
public bool StrictEnforcement { get; set; } = true;
|
||||
|
||||
public int MaxDriftSeconds { get; set; } = 86400;
|
||||
|
||||
public bool AllowMissingAnchorInUnsealedMode { get; set; } = true;
|
||||
|
||||
public IReadOnlyList<string> StrictOperations { get; set; } = new[]
|
||||
{
|
||||
"bundle.import",
|
||||
"attestation.sign",
|
||||
"audit.record"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of time-anchor policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorPolicyResult(
|
||||
bool Allowed,
|
||||
string? ErrorCode,
|
||||
string? Reason,
|
||||
string? Remediation,
|
||||
StalenessEvaluation? Staleness);
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class TimeAnchorPolicyService
|
||||
{
|
||||
public async Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
DateTimeOffset? bundleTimestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
|
||||
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!baseResult.Allowed)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
if (bundleTimestamp.HasValue)
|
||||
{
|
||||
var driftResult = await CalculateDriftAsync(tenantId, bundleTimestamp.Value, cancellationToken).ConfigureAwait(false);
|
||||
if (driftResult.DriftExceedsThreshold)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle {BundleId} timestamp drift exceeds threshold for tenant {TenantId}: drift={DriftSeconds}s > max={MaxDriftSeconds}s [{ErrorCode}]",
|
||||
bundleId, tenantId, driftResult.Drift.TotalSeconds, _options.MaxDriftSeconds, TimeAnchorPolicyErrorCodes.DriftExceeded);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.DriftExceeded,
|
||||
Reason: $"Bundle timestamp drift exceeds maximum ({driftResult.Drift.TotalSeconds:F0}s > {_options.MaxDriftSeconds}s)",
|
||||
Remediation: "Bundle is too old or time anchor is significantly out of sync. Refresh the time anchor or use a more recent bundle.",
|
||||
Staleness: baseResult.Staleness);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Bundle import policy passed for tenant {TenantId}, bundle {BundleId}", tenantId, bundleId);
|
||||
return baseResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class TimeAnchorPolicyService
|
||||
{
|
||||
public async Task<TimeAnchorDriftResult> CalculateDriftAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset targetTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!status.HasAnchor)
|
||||
{
|
||||
return new TimeAnchorDriftResult(
|
||||
HasAnchor: false,
|
||||
Drift: TimeSpan.Zero,
|
||||
DriftExceedsThreshold: false,
|
||||
AnchorTime: null);
|
||||
}
|
||||
|
||||
var drift = targetTime - status.Anchor!.AnchorTime;
|
||||
var absDriftSeconds = Math.Abs(drift.TotalSeconds);
|
||||
var exceedsThreshold = absDriftSeconds > _options.MaxDriftSeconds;
|
||||
|
||||
return new TimeAnchorDriftResult(
|
||||
HasAnchor: true,
|
||||
Drift: drift,
|
||||
DriftExceedsThreshold: exceedsThreshold,
|
||||
AnchorTime: status.Anchor.AnchorTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class TimeAnchorPolicyService
|
||||
{
|
||||
public async Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
|
||||
string tenantId,
|
||||
string operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(operation);
|
||||
|
||||
var isStrictOperation = _options.StrictOperations.Contains(operation, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (isStrictOperation)
|
||||
{
|
||||
var result = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.Allowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Strict operation {Operation} blocked for tenant {TenantId}: {Reason} [{ErrorCode}]",
|
||||
operation, tenantId, result.Reason, result.ErrorCode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!baseResult.Allowed && !_options.StrictEnforcement)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Non-strict operation {Operation} allowed for tenant {TenantId} despite policy issue: {Reason}",
|
||||
operation, tenantId, baseResult.Reason);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: baseResult.ErrorCode,
|
||||
Reason: $"operation-allowed-with-warning:{baseResult.Reason}",
|
||||
Remediation: baseResult.Remediation,
|
||||
Staleness: baseResult.Staleness);
|
||||
}
|
||||
|
||||
return baseResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed partial class TimeAnchorPolicyService
|
||||
{
|
||||
public async Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!status.HasAnchor)
|
||||
{
|
||||
if (_options.AllowMissingAnchorInUnsealedMode && !_options.StrictEnforcement)
|
||||
{
|
||||
_logger.LogDebug("Time anchor missing for tenant {TenantId}, allowed in non-strict mode", tenantId);
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: null,
|
||||
Reason: "time-anchor-missing-allowed",
|
||||
Remediation: null,
|
||||
Staleness: null);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Time anchor missing for tenant {TenantId} [{ErrorCode}]",
|
||||
tenantId, TimeAnchorPolicyErrorCodes.AnchorMissing);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorMissing,
|
||||
Reason: "No time anchor configured for tenant",
|
||||
Remediation: "Set a time anchor using POST /api/v1/time/anchor with a valid Roughtime or RFC3161 token",
|
||||
Staleness: null);
|
||||
}
|
||||
|
||||
var staleness = status.Staleness;
|
||||
|
||||
if (staleness.IsBreach)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Time anchor staleness breached for tenant {TenantId}: age={AgeSeconds}s > breach={BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorBreached);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorBreached,
|
||||
Reason: $"Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s)",
|
||||
Remediation: "Refresh time anchor with a new token to continue operations",
|
||||
Staleness: staleness);
|
||||
}
|
||||
|
||||
if (staleness.IsWarning)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Time anchor staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorStale);
|
||||
}
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: null,
|
||||
Reason: staleness.IsWarning ? "time-anchor-warning" : "time-anchor-valid",
|
||||
Remediation: staleness.IsWarning ? "Consider refreshing time anchor soon" : null,
|
||||
Staleness: staleness);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +1,13 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using System;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Policy enforcement service for time anchors.
|
||||
/// Per AIRGAP-TIME-57-001: Enforces time-anchor requirements in sealed-mode operations.
|
||||
/// </summary>
|
||||
public interface ITimeAnchorPolicyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that a valid time anchor exists and is not stale.
|
||||
/// </summary>
|
||||
Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enforces time-anchor requirements before bundle import.
|
||||
/// </summary>
|
||||
Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
DateTimeOffset? bundleTimestamp,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Enforces time-anchor requirements before operations that require trusted time.
|
||||
/// </summary>
|
||||
Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
|
||||
string tenantId,
|
||||
string operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time drift between the anchor and a given timestamp.
|
||||
/// </summary>
|
||||
Task<TimeAnchorDriftResult> CalculateDriftAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset targetTime,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of time-anchor policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorPolicyResult(
|
||||
bool Allowed,
|
||||
string? ErrorCode,
|
||||
string? Reason,
|
||||
string? Remediation,
|
||||
StalenessEvaluation? Staleness);
|
||||
|
||||
/// <summary>
|
||||
/// Result of time drift calculation.
|
||||
/// </summary>
|
||||
public sealed record TimeAnchorDriftResult(
|
||||
bool HasAnchor,
|
||||
TimeSpan Drift,
|
||||
bool DriftExceedsThreshold,
|
||||
DateTimeOffset? AnchorTime);
|
||||
|
||||
/// <summary>
|
||||
/// Policy configuration for time anchors.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorPolicyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to enforce strict time-anchor requirements.
|
||||
/// When true, operations fail if time anchor is missing or stale.
|
||||
/// </summary>
|
||||
public bool StrictEnforcement { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed drift between anchor time and operation time in seconds.
|
||||
/// </summary>
|
||||
public int MaxDriftSeconds { get; set; } = 86400; // 24 hours
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow operations when no time anchor exists (unsealed mode only).
|
||||
/// </summary>
|
||||
public bool AllowMissingAnchorInUnsealedMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Operations that require strict time-anchor enforcement regardless of mode.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> StrictOperations { get; set; } = new[]
|
||||
{
|
||||
"bundle.import",
|
||||
"attestation.sign",
|
||||
"audit.record"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error codes for time-anchor policy violations.
|
||||
/// </summary>
|
||||
public static class TimeAnchorPolicyErrorCodes
|
||||
{
|
||||
public const string AnchorMissing = "TIME_ANCHOR_MISSING";
|
||||
public const string AnchorStale = "TIME_ANCHOR_STALE";
|
||||
public const string AnchorBreached = "TIME_ANCHOR_BREACHED";
|
||||
public const string DriftExceeded = "TIME_ANCHOR_DRIFT_EXCEEDED";
|
||||
public const string PolicyViolation = "TIME_ANCHOR_POLICY_VIOLATION";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of time-anchor policy service.
|
||||
/// </summary>
|
||||
public sealed class TimeAnchorPolicyService : ITimeAnchorPolicyService
|
||||
public sealed partial class TimeAnchorPolicyService : ITimeAnchorPolicyService
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly TimeAnchorPolicyOptions _options;
|
||||
@@ -125,182 +25,4 @@ public sealed class TimeAnchorPolicyService : ITimeAnchorPolicyService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<TimeAnchorPolicyResult> ValidateTimeAnchorAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check if anchor exists
|
||||
if (!status.HasAnchor)
|
||||
{
|
||||
if (_options.AllowMissingAnchorInUnsealedMode && !_options.StrictEnforcement)
|
||||
{
|
||||
_logger.LogDebug("Time anchor missing for tenant {TenantId}, allowed in non-strict mode", tenantId);
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: null,
|
||||
Reason: "time-anchor-missing-allowed",
|
||||
Remediation: null,
|
||||
Staleness: null);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Time anchor missing for tenant {TenantId} [{ErrorCode}]",
|
||||
tenantId, TimeAnchorPolicyErrorCodes.AnchorMissing);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorMissing,
|
||||
Reason: "No time anchor configured for tenant",
|
||||
Remediation: "Set a time anchor using POST /api/v1/time/anchor with a valid Roughtime or RFC3161 token",
|
||||
Staleness: null);
|
||||
}
|
||||
|
||||
// Evaluate staleness
|
||||
var staleness = status.Staleness;
|
||||
|
||||
// Check for breach
|
||||
if (staleness.IsBreach)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Time anchor staleness breached for tenant {TenantId}: age={AgeSeconds}s > breach={BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorBreached);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.AnchorBreached,
|
||||
Reason: $"Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s)",
|
||||
Remediation: "Refresh time anchor with a new token to continue operations",
|
||||
Staleness: staleness);
|
||||
}
|
||||
|
||||
// Check for warning (allowed but logged)
|
||||
if (staleness.IsWarning)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Time anchor staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
|
||||
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, TimeAnchorPolicyErrorCodes.AnchorStale);
|
||||
}
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: null,
|
||||
Reason: staleness.IsWarning ? "time-anchor-warning" : "time-anchor-valid",
|
||||
Remediation: staleness.IsWarning ? "Consider refreshing time anchor soon" : null,
|
||||
Staleness: staleness);
|
||||
}
|
||||
|
||||
public async Task<TimeAnchorPolicyResult> EnforceBundleImportPolicyAsync(
|
||||
string tenantId,
|
||||
string bundleId,
|
||||
DateTimeOffset? bundleTimestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
|
||||
// First validate basic time anchor requirements
|
||||
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!baseResult.Allowed)
|
||||
{
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
// If bundle has a timestamp, check drift
|
||||
if (bundleTimestamp.HasValue)
|
||||
{
|
||||
var driftResult = await CalculateDriftAsync(tenantId, bundleTimestamp.Value, cancellationToken).ConfigureAwait(false);
|
||||
if (driftResult.DriftExceedsThreshold)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Bundle {BundleId} timestamp drift exceeds threshold for tenant {TenantId}: drift={DriftSeconds}s > max={MaxDriftSeconds}s [{ErrorCode}]",
|
||||
bundleId, tenantId, driftResult.Drift.TotalSeconds, _options.MaxDriftSeconds, TimeAnchorPolicyErrorCodes.DriftExceeded);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: false,
|
||||
ErrorCode: TimeAnchorPolicyErrorCodes.DriftExceeded,
|
||||
Reason: $"Bundle timestamp drift exceeds maximum ({driftResult.Drift.TotalSeconds:F0}s > {_options.MaxDriftSeconds}s)",
|
||||
Remediation: "Bundle is too old or time anchor is significantly out of sync. Refresh the time anchor or use a more recent bundle.",
|
||||
Staleness: baseResult.Staleness);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Bundle import policy passed for tenant {TenantId}, bundle {BundleId}", tenantId, bundleId);
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
public async Task<TimeAnchorPolicyResult> EnforceOperationPolicyAsync(
|
||||
string tenantId,
|
||||
string operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(operation);
|
||||
|
||||
var isStrictOperation = _options.StrictOperations.Contains(operation, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// For strict operations, always require valid time anchor
|
||||
if (isStrictOperation)
|
||||
{
|
||||
var result = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.Allowed)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Strict operation {Operation} blocked for tenant {TenantId}: {Reason} [{ErrorCode}]",
|
||||
operation, tenantId, result.Reason, result.ErrorCode);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// For non-strict operations, allow with warning if anchor is missing/stale
|
||||
var baseResult = await ValidateTimeAnchorAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!baseResult.Allowed && !_options.StrictEnforcement)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Non-strict operation {Operation} allowed for tenant {TenantId} despite policy issue: {Reason}",
|
||||
operation, tenantId, baseResult.Reason);
|
||||
|
||||
return new TimeAnchorPolicyResult(
|
||||
Allowed: true,
|
||||
ErrorCode: baseResult.ErrorCode,
|
||||
Reason: $"operation-allowed-with-warning:{baseResult.Reason}",
|
||||
Remediation: baseResult.Remediation,
|
||||
Staleness: baseResult.Staleness);
|
||||
}
|
||||
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
public async Task<TimeAnchorDriftResult> CalculateDriftAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset targetTime,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var status = await _statusService.GetStatusAsync(tenantId, now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!status.HasAnchor)
|
||||
{
|
||||
return new TimeAnchorDriftResult(
|
||||
HasAnchor: false,
|
||||
Drift: TimeSpan.Zero,
|
||||
DriftExceedsThreshold: false,
|
||||
AnchorTime: null);
|
||||
}
|
||||
|
||||
var drift = targetTime - status.Anchor!.AnchorTime;
|
||||
var absDriftSeconds = Math.Abs(drift.TotalSeconds);
|
||||
var exceedsThreshold = absDriftSeconds > _options.MaxDriftSeconds;
|
||||
|
||||
return new TimeAnchorDriftResult(
|
||||
HasAnchor: true,
|
||||
Drift: drift,
|
||||
DriftExceedsThreshold: exceedsThreshold,
|
||||
AnchorTime: status.Anchor.AnchorTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ public sealed class TimeStatusService
|
||||
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)
|
||||
{
|
||||
budget.Validate();
|
||||
await _store.SetAsync(tenantId, anchor, budget, cancellationToken);
|
||||
await _store.SetAsync(tenantId, anchor, budget, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<TimeStatus> GetStatusAsync(string tenantId, DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken);
|
||||
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var eval = _calculator.Evaluate(anchor, budget, nowUtc);
|
||||
var content = _calculator.EvaluateContent(anchor, _contentBudgets, nowUtc);
|
||||
var status = new TimeStatus(anchor, eval, budget, content, nowUtc);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
|
||||
public sealed class TimeTelemetry
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.AirGap.Time", "1.0.0");
|
||||
private static readonly Meter _meter = new("StellaOps.AirGap.Time", "1.0.0");
|
||||
private const int MaxEntries = 1024;
|
||||
// Bound eviction queue to 3x max entries to prevent unbounded memory growth
|
||||
private const int MaxEvictionQueueSize = MaxEntries * 3;
|
||||
@@ -16,13 +16,13 @@ public sealed class TimeTelemetry
|
||||
|
||||
private readonly ObservableGauge<long> _anchorAgeGauge;
|
||||
|
||||
private static readonly Counter<long> StatusCounter = Meter.CreateCounter<long>("airgap_time_anchor_status_total");
|
||||
private static readonly Counter<long> WarningCounter = Meter.CreateCounter<long>("airgap_time_anchor_warning_total");
|
||||
private static readonly Counter<long> BreachCounter = Meter.CreateCounter<long>("airgap_time_anchor_breach_total");
|
||||
private static readonly Counter<long> _statusCounter = _meter.CreateCounter<long>("airgap_time_anchor_status_total");
|
||||
private static readonly Counter<long> _warningCounter = _meter.CreateCounter<long>("airgap_time_anchor_warning_total");
|
||||
private static readonly Counter<long> _breachCounter = _meter.CreateCounter<long>("airgap_time_anchor_breach_total");
|
||||
|
||||
public TimeTelemetry()
|
||||
{
|
||||
_anchorAgeGauge = Meter.CreateObservableGauge(
|
||||
_anchorAgeGauge = _meter.CreateObservableGauge(
|
||||
"airgap_time_anchor_age_seconds",
|
||||
() => _latest.Select(kvp => new Measurement<long>(kvp.Value.AgeSeconds, new KeyValuePair<string, object?>("tenant", kvp.Key))));
|
||||
}
|
||||
@@ -47,17 +47,17 @@ public sealed class TimeTelemetry
|
||||
{ "is_breach", status.Staleness.IsBreach }
|
||||
};
|
||||
|
||||
StatusCounter.Add(1, tags);
|
||||
_statusCounter.Add(1, tags);
|
||||
|
||||
if (status.Staleness.IsWarning)
|
||||
{
|
||||
WarningCounter.Add(1, tags);
|
||||
_warningCounter.Add(1, tags);
|
||||
}
|
||||
|
||||
if (status.Staleness.IsBreach)
|
||||
{
|
||||
BreachCounter.Add(1, tags);
|
||||
}
|
||||
_breachCounter.Add(1, tags);
|
||||
}
|
||||
}
|
||||
|
||||
public Snapshot? GetLatest(string tenantId)
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user