This commit is contained in:
master
2026-02-04 19:59:20 +02:00
parent 557feefdc3
commit 5548cf83bf
1479 changed files with 53557 additions and 40339 deletions

171
devops/compose/.env Normal file
View 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

View File

@@ -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

View 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;

View 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"
}
}

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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");

View File

@@ -156,6 +156,7 @@ 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”.
* **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:**

View File

@@ -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) |

View File

@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMBVhnaKPP51XonFF
s6a3c38QknKe7QE/2uG/Me87/1WhRANCAAT9pvHVdj0b4ipmeG5hO+6vIkKef3iz
YCsDck4n0plEreGU6RQqjbNvonaz4RBfZgfRRijO9uwYd+6TRRba5Ud2
-----END PRIVATE KEY-----

View 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-----

View File

@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgSrZgb+IjZY+zqdUF
9R3gUmL0xtruaXM7vEfvlo/8JP6hRANCAATH0tmszlWm2+QfQ4w77TwFVINslHsj
glBQ6RP1JbDjWJD03jgtN+CQDAU07AMGN53J9K5sKGVUV4zMbnNN96Sd
-----END PRIVATE KEY-----

Binary file not shown.

View File

@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg9CXTA+ckwlbRXIPx
jH2M2A8qIv0edRVA9zDM2GL1i7ahRANCAAQsUvdTeXbrxwoZ079ZY67F4292WsQ4
/XDHJtursur+I0bTow9ARTiJXLDeWwRiaVo5uujewBLutxhK45xwYLFJ
-----END PRIVATE KEY-----

View 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

View 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
}

View 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
View 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
View 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 '============================================='

View File

@@ -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>

View File

@@ -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();

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
}
}
""";
}

View File

@@ -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()
public async Task ReportsDiagnostic_ForNewHttpClientAsync()
{
const string source = """
using System.Net.Http;
@@ -44,7 +30,7 @@ public sealed class HttpClientUsageAnalyzerTests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DoesNotReportDiagnostic_InsidePolicyAssembly()
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();
}
}
""";
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}
""";
}

View File

@@ -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
}

View File

@@ -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. |

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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. |

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -1,18 +1,11 @@
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]
@@ -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;
}
}
}

View File

@@ -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. |

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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.";
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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})";
}

View File

@@ -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);
}
}

View File

@@ -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})";
}

View File

@@ -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. |

View File

@@ -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));
}
}

View File

@@ -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)
{

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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();

View 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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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}");
}
}
}

View File

@@ -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
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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";
}

View File

@@ -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"
};
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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,16 +47,16 @@ 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);
}
}

View File

@@ -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