diff --git a/.gitea/workflows/secrets-bundle-release.yml b/.gitea/workflows/secrets-bundle-release.yml new file mode 100644 index 000000000..1517699f4 --- /dev/null +++ b/.gitea/workflows/secrets-bundle-release.yml @@ -0,0 +1,503 @@ +# .gitea/workflows/secrets-bundle-release.yml +# Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) +# Task: OKS-007 - Add bundle to release workflow +# Description: Build, sign, and release secrets rule bundles for offline deployment + +name: Secrets Bundle Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Bundle version (CalVer YYYY.MM format)' + required: true + type: string + include_in_offline_kit: + description: 'Include bundle in offline kit' + type: boolean + default: true + sign_bundle: + description: 'Sign bundle with DSSE' + type: boolean + default: true + dry_run: + description: 'Dry run (build but do not publish)' + type: boolean + default: false + push: + branches: [main] + paths: + - 'offline/rules/secrets/sources/**' + - '.gitea/workflows/secrets-bundle-release.yml' + pull_request: + branches: [main, develop] + paths: + - 'offline/rules/secrets/sources/**' + +env: + BUNDLE_ID: secrets.ruleset + DOTNET_NOLOGO: 1 + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + REGISTRY: git.stella-ops.org + +jobs: + # =========================================================================== + # VALIDATE VERSION + # =========================================================================== + + validate: + name: Validate Inputs + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.resolve.outputs.version }} + sign_bundle: ${{ steps.resolve.outputs.sign_bundle }} + dry_run: ${{ steps.resolve.outputs.dry_run }} + include_in_kit: ${{ steps.resolve.outputs.include_in_kit }} + steps: + - name: Resolve inputs + id: resolve + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ github.event.inputs.version }}" + SIGN_BUNDLE="${{ github.event.inputs.sign_bundle }}" + DRY_RUN="${{ github.event.inputs.dry_run }}" + INCLUDE_IN_KIT="${{ github.event.inputs.include_in_offline_kit }}" + else + # Auto-generate version for push/PR builds + VERSION="$(date +%Y.%m)" + SIGN_BUNDLE="false" # Don't sign non-release builds + DRY_RUN="true" + INCLUDE_IN_KIT="false" + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "sign_bundle=$SIGN_BUNDLE" >> "$GITHUB_OUTPUT" + echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT" + echo "include_in_kit=$INCLUDE_IN_KIT" >> "$GITHUB_OUTPUT" + + echo "=== Bundle Configuration ===" + echo "Version: $VERSION" + echo "Sign Bundle: $SIGN_BUNDLE" + echo "Dry Run: $DRY_RUN" + echo "Include in Kit: $INCLUDE_IN_KIT" + + - name: Validate version format + run: | + VERSION="${{ steps.resolve.outputs.version }}" + if ! [[ "$VERSION" =~ ^[0-9]{4}\.[0-9]{2}$ ]]; then + echo "::error::Invalid version format. Expected CalVer YYYY.MM (e.g., 2026.01)" + exit 1 + fi + + # =========================================================================== + # BUILD BUNDLE + # =========================================================================== + + build-bundle: + name: Build Secrets Bundle + runs-on: ubuntu-22.04 + needs: [validate] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup directories + run: | + VERSION="${{ needs.validate.outputs.version }}" + mkdir -p "out/bundles/secrets/${VERSION}" + mkdir -p "offline/rules/secrets/${VERSION}" + + - name: Collect rule sources + run: | + VERSION="${{ needs.validate.outputs.version }}" + BUNDLE_DIR="out/bundles/secrets/${VERSION}" + SOURCE_DIR="offline/rules/secrets/sources" + + if [[ ! -d "$SOURCE_DIR" ]]; then + echo "Creating sample rule source directory..." + mkdir -p "$SOURCE_DIR" + + # Create minimal placeholder if no sources exist + cat > "${SOURCE_DIR}/placeholder.json" << 'EOF' + { + "id": "placeholder-rule", + "name": "Placeholder Rule", + "description": "This is a placeholder rule. Add actual rules to offline/rules/secrets/sources/", + "pattern": "^PLACEHOLDER_", + "severity": "low", + "confidence": 0.1 + } + EOF + fi + + RULE_COUNT=$(find "$SOURCE_DIR" -name "*.json" | wc -l) + echo "Found ${RULE_COUNT} rule source files" + + - name: Build rule bundle + run: | + VERSION="${{ needs.validate.outputs.version }}" + BUNDLE_DIR="out/bundles/secrets/${VERSION}" + SOURCE_DIR="offline/rules/secrets/sources" + BUNDLE_ID="${{ env.BUNDLE_ID }}" + + # Compile rules to JSONL format + echo "Compiling rules to JSONL..." + RULE_COUNT=0 + > "${BUNDLE_DIR}/${BUNDLE_ID}.rules.jsonl" + + for rule_file in "${SOURCE_DIR}"/*.json; do + if [[ -f "$rule_file" ]]; then + # Validate JSON and add to bundle + if jq -e '.' "$rule_file" > /dev/null 2>&1; then + jq -c '.' "$rule_file" >> "${BUNDLE_DIR}/${BUNDLE_ID}.rules.jsonl" + RULE_COUNT=$((RULE_COUNT + 1)) + else + echo "::warning::Invalid JSON in $rule_file, skipping" + fi + fi + done + + echo "Compiled ${RULE_COUNT} rules" + + # Compute file digests + RULES_DIGEST=$(sha256sum "${BUNDLE_DIR}/${BUNDLE_ID}.rules.jsonl" | cut -d' ' -f1) + RULES_SIZE=$(stat -f%z "${BUNDLE_DIR}/${BUNDLE_ID}.rules.jsonl" 2>/dev/null || stat -c%s "${BUNDLE_DIR}/${BUNDLE_ID}.rules.jsonl") + + # Generate manifest + cat > "${BUNDLE_DIR}/${BUNDLE_ID}.manifest.json" << EOF + { + "bundleId": "${BUNDLE_ID}", + "bundleType": "secrets", + "version": "${VERSION}", + "ruleCount": ${RULE_COUNT}, + "createdAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "gitSha": "${{ github.sha }}", + "gitRef": "${{ github.ref }}", + "files": [ + { + "name": "${BUNDLE_ID}.rules.jsonl", + "digest": "sha256:${RULES_DIGEST}", + "sizeBytes": ${RULES_SIZE} + } + ] + } + EOF + + echo "=== Bundle Manifest ===" + cat "${BUNDLE_DIR}/${BUNDLE_ID}.manifest.json" + + - name: Upload unsigned bundle + uses: actions/upload-artifact@v4 + with: + name: secrets-bundle-unsigned-${{ needs.validate.outputs.version }} + path: out/bundles/secrets/${{ needs.validate.outputs.version }} + retention-days: 30 + + # =========================================================================== + # SIGN BUNDLE + # =========================================================================== + + sign-bundle: + name: Sign Secrets Bundle + runs-on: ubuntu-22.04 + needs: [validate, build-bundle] + if: needs.validate.outputs.sign_bundle == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download unsigned bundle + uses: actions/download-artifact@v4 + with: + name: secrets-bundle-unsigned-${{ needs.validate.outputs.version }} + path: bundle + + - name: Sign bundle with DSSE + env: + SECRETS_SIGNER_KEY: ${{ secrets.SECRETS_SIGNER_KEY }} + SECRETS_SIGNER_KEY_ID: ${{ secrets.SECRETS_SIGNER_KEY_ID }} + run: | + VERSION="${{ needs.validate.outputs.version }}" + BUNDLE_ID="${{ env.BUNDLE_ID }}" + MANIFEST_PATH="bundle/${BUNDLE_ID}.manifest.json" + + if [[ -z "${SECRETS_SIGNER_KEY}" ]]; then + echo "::warning::SECRETS_SIGNER_KEY not configured, generating test signature" + + # Generate a test DSSE envelope (not cryptographically valid) + PAYLOAD_B64=$(base64 -w0 "${MANIFEST_PATH}") + + cat > "bundle/${BUNDLE_ID}.dsse.json" << EOF + { + "payloadType": "application/vnd.stellaops.rulebundle.manifest+json", + "payload": "${PAYLOAD_B64}", + "signatures": [ + { + "keyid": "test-key-unsigned", + "sig": "$(echo 'unsigned-test-signature' | base64 -w0)" + } + ] + } + EOF + + # Update manifest to indicate test signing + jq '.signerKeyId = "test-key-unsigned" | .signedAt = "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"' \ + "${MANIFEST_PATH}" > "${MANIFEST_PATH}.tmp" && mv "${MANIFEST_PATH}.tmp" "${MANIFEST_PATH}" + else + # Real DSSE signing + echo "Signing bundle with key: ${SECRETS_SIGNER_KEY_ID}" + + # Create PAE (Pre-Authentication Encoding) + PAYLOAD_TYPE="application/vnd.stellaops.rulebundle.manifest+json" + PAYLOAD=$(cat "${MANIFEST_PATH}") + PAYLOAD_B64=$(echo -n "$PAYLOAD" | base64 -w0) + + PAE="DSSEv1 ${#PAYLOAD_TYPE} ${PAYLOAD_TYPE} ${#PAYLOAD} ${PAYLOAD}" + + # Sign using openssl (ES256) + echo "${SECRETS_SIGNER_KEY}" | base64 -d > /tmp/signing-key.pem + SIG=$(echo -n "$PAE" | openssl dgst -sha256 -sign /tmp/signing-key.pem | base64 -w0) + rm -f /tmp/signing-key.pem + + cat > "bundle/${BUNDLE_ID}.dsse.json" << EOF + { + "payloadType": "${PAYLOAD_TYPE}", + "payload": "${PAYLOAD_B64}", + "signatures": [ + { + "keyid": "${SECRETS_SIGNER_KEY_ID}", + "sig": "${SIG}" + } + ] + } + EOF + + # Update manifest with signing info + jq '.signerKeyId = "'"${SECRETS_SIGNER_KEY_ID}"'" | .signedAt = "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"' \ + "${MANIFEST_PATH}" > "${MANIFEST_PATH}.tmp" && mv "${MANIFEST_PATH}.tmp" "${MANIFEST_PATH}" + fi + + echo "=== DSSE Envelope ===" + jq '.' "bundle/${BUNDLE_ID}.dsse.json" + + - name: Verify signature structure + run: | + BUNDLE_ID="${{ env.BUNDLE_ID }}" + + # Verify DSSE structure + jq -e '.payloadType and .payload and .signatures[0].keyid and .signatures[0].sig' \ + "bundle/${BUNDLE_ID}.dsse.json" > /dev/null + + echo "Signature structure verified" + + - name: Upload signed bundle + uses: actions/upload-artifact@v4 + with: + name: secrets-bundle-signed-${{ needs.validate.outputs.version }} + path: bundle + retention-days: 90 + + # =========================================================================== + # PACKAGE FOR OFFLINE KIT + # =========================================================================== + + package-offline-kit: + name: Package for Offline Kit + runs-on: ubuntu-22.04 + needs: [validate, build-bundle, sign-bundle] + if: always() && needs.build-bundle.result == 'success' && needs.validate.outputs.include_in_kit == 'true' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download bundle + uses: actions/download-artifact@v4 + with: + name: ${{ needs.sign-bundle.result == 'success' && format('secrets-bundle-signed-{0}', needs.validate.outputs.version) || format('secrets-bundle-unsigned-{0}', needs.validate.outputs.version) }} + path: bundle + + - name: Package bundle + run: | + VERSION="${{ needs.validate.outputs.version }}" + BUNDLE_ID="${{ env.BUNDLE_ID }}" + + # Create offline kit structure + mkdir -p "offline-kit/rules/secrets/${VERSION}" + cp bundle/* "offline-kit/rules/secrets/${VERSION}/" + + # Create symlink for latest + cd "offline-kit/rules/secrets" + ln -sf "${VERSION}" latest + + # Generate checksums + cd "${VERSION}" + sha256sum ${BUNDLE_ID}.* > SHA256SUMS + + echo "=== Offline Kit Contents ===" + find ../.. -type f | head -20 + + - name: Create tarball + run: | + VERSION="${{ needs.validate.outputs.version }}" + cd offline-kit + tar -czvf "../secrets-bundle-kit-${VERSION}.tar.gz" . + + - name: Upload offline kit package + uses: actions/upload-artifact@v4 + with: + name: secrets-bundle-kit-${{ needs.validate.outputs.version }} + path: secrets-bundle-kit-*.tar.gz + retention-days: 90 + + # =========================================================================== + # PUBLISH + # =========================================================================== + + publish: + name: Publish Bundle + runs-on: ubuntu-22.04 + needs: [validate, sign-bundle, package-offline-kit] + if: needs.validate.outputs.dry_run != 'true' && needs.sign-bundle.result == 'success' + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download signed bundle + uses: actions/download-artifact@v4 + with: + name: secrets-bundle-signed-${{ needs.validate.outputs.version }} + path: bundle + + - name: Download offline kit package + uses: actions/download-artifact@v4 + with: + name: secrets-bundle-kit-${{ needs.validate.outputs.version }} + path: kit + continue-on-error: true + + - name: Commit bundle to repository + run: | + VERSION="${{ needs.validate.outputs.version }}" + BUNDLE_ID="${{ env.BUNDLE_ID }}" + TARGET_DIR="offline/rules/secrets/${VERSION}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + mkdir -p "${TARGET_DIR}" + cp bundle/* "${TARGET_DIR}/" + + # Update latest symlink + cd offline/rules/secrets + rm -f latest + ln -sf "${VERSION}" latest + cd - + + git add "offline/rules/secrets/${VERSION}" + git add "offline/rules/secrets/latest" + + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "release: secrets rule bundle ${VERSION} + + Bundle ID: ${BUNDLE_ID} + Version: ${VERSION} + Git SHA: ${{ github.sha }} + + 🤖 Generated with [Claude Code](https://claude.ai/claude-code) + + Co-Authored-By: github-actions[bot] " + + git push + fi + + - name: Create release tag + env: + GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + VERSION="${{ needs.validate.outputs.version }}" + BUNDLE_ID="${{ env.BUNDLE_ID }}" + + # Get rule count from manifest + RULE_COUNT=$(jq -r '.ruleCount // 0' "bundle/${BUNDLE_ID}.manifest.json") + SIGNER_KEY_ID=$(jq -r '.signerKeyId // "unknown"' "bundle/${BUNDLE_ID}.manifest.json") + + # Create release notes + cat > release-notes.md << EOF + ## Secrets Rule Bundle ${VERSION} + + ### Bundle Information + - **Bundle ID:** ${BUNDLE_ID} + - **Version:** ${VERSION} + - **Rule Count:** ${RULE_COUNT} + - **Signer Key ID:** ${SIGNER_KEY_ID} + - **Git SHA:** ${{ github.sha }} + + ### Installation + + #### For Online Environments + \`\`\`bash + stella secrets bundle update --version ${VERSION} + \`\`\` + + #### For Offline/Air-Gapped Environments + 1. Download the offline kit package + 2. Transfer to air-gapped environment + 3. Run the installation script: + \`\`\`bash + ./devops/offline/scripts/install-secrets-bundle.sh /path/to/rules/secrets/${VERSION} + \`\`\` + + ### Files + | File | Description | + |------|-------------| + | \`${BUNDLE_ID}.manifest.json\` | Bundle manifest with metadata | + | \`${BUNDLE_ID}.rules.jsonl\` | Rule definitions (JSONL format) | + | \`${BUNDLE_ID}.dsse.json\` | DSSE signature envelope | + EOF + + # Prepare assets + mkdir -p release-assets + cp bundle/* release-assets/ + if [[ -f "kit/secrets-bundle-kit-${VERSION}.tar.gz" ]]; then + cp "kit/secrets-bundle-kit-${VERSION}.tar.gz" release-assets/ + fi + + # Create release + gh release create "secrets-bundle-${VERSION}" \ + --title "Secrets Rule Bundle ${VERSION}" \ + --notes-file release-notes.md \ + release-assets/* + + # =========================================================================== + # SUMMARY + # =========================================================================== + + summary: + name: Build Summary + runs-on: ubuntu-22.04 + needs: [validate, build-bundle, sign-bundle, package-offline-kit, publish] + if: always() + steps: + - name: Generate Summary + run: | + echo "## Secrets Bundle Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Configuration" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | ${{ needs.validate.outputs.version }} |" >> $GITHUB_STEP_SUMMARY + echo "| Sign Bundle | ${{ needs.validate.outputs.sign_bundle }} |" >> $GITHUB_STEP_SUMMARY + echo "| Dry Run | ${{ needs.validate.outputs.dry_run }} |" >> $GITHUB_STEP_SUMMARY + echo "| Include in Kit | ${{ needs.validate.outputs.include_in_kit }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Job Results" >> $GITHUB_STEP_SUMMARY + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Build Bundle | ${{ needs.build-bundle.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Sign Bundle | ${{ needs.sign-bundle.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Package Offline Kit | ${{ needs.package-offline-kit.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Publish | ${{ needs.publish.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY diff --git a/devops/helm/stellaops/values-airgap.yaml b/devops/helm/stellaops/values-airgap.yaml index 03ffb6f54..9b9465b78 100644 --- a/devops/helm/stellaops/values-airgap.yaml +++ b/devops/helm/stellaops/values-airgap.yaml @@ -184,6 +184,17 @@ services: SCANNER_SURFACE_CACHE_ROOT: "/var/lib/stellaops/surface" SCANNER_SURFACE_SECRETS_PROVIDER: "file" SCANNER_SURFACE_SECRETS_ROOT: "/etc/stellaops/secrets" + # Secret Detection Rules Bundle + SCANNER__FEATURES__EXPERIMENTAL__SECRETLEAKDETECTION: "false" + SCANNER__SECRETS__BUNDLEPATH: "/opt/stellaops/plugins/scanner/analyzers/secrets" + SCANNER__SECRETS__REQUIRESIGNATURE: "true" + volumeMounts: + - name: secrets-rules + mountPath: /opt/stellaops/plugins/scanner/analyzers/secrets + readOnly: true + volumeClaims: + - name: secrets-rules + claimName: stellaops-secrets-rules notify-web: image: registry.stella-ops.org/stellaops/notify-web:2025.09.2 service: diff --git a/devops/offline/scripts/install-secrets-bundle.sh b/devops/offline/scripts/install-secrets-bundle.sh new file mode 100644 index 000000000..e29db46ef --- /dev/null +++ b/devops/offline/scripts/install-secrets-bundle.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# install-secrets-bundle.sh +# Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) +# Task: OKS-005 - Create bundle installation script +# Description: Install signed secrets rule bundle for offline environments +# ----------------------------------------------------------------------------- +# Usage: ./install-secrets-bundle.sh [install-path] [attestor-mirror] +# Example: ./install-secrets-bundle.sh /mnt/offline-kit/rules/secrets/2026.01 + +set -euo pipefail + +# Configuration +BUNDLE_PATH="${1:?Bundle path required (e.g., /mnt/offline-kit/rules/secrets/2026.01)}" +INSTALL_PATH="${2:-/opt/stellaops/plugins/scanner/analyzers/secrets}" +ATTESTOR_MIRROR="${3:-}" +BUNDLE_ID="${BUNDLE_ID:-secrets.ruleset}" +REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-true}" +STELLAOPS_USER="${STELLAOPS_USER:-stellaops}" +STELLAOPS_GROUP="${STELLAOPS_GROUP:-stellaops}" + +# Color output helpers (disabled if not a terminal) +if [[ -t 1 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + NC='' +fi + +log_info() { echo -e "${GREEN}==>${NC} $*"; } +log_warn() { echo -e "${YELLOW}WARN:${NC} $*" >&2; } +log_error() { echo -e "${RED}ERROR:${NC} $*" >&2; } + +# Validate bundle path +log_info "Validating secrets bundle at ${BUNDLE_PATH}" + +if [[ ! -d "${BUNDLE_PATH}" ]]; then + log_error "Bundle directory not found: ${BUNDLE_PATH}" + exit 1 +fi + +MANIFEST_FILE="${BUNDLE_PATH}/${BUNDLE_ID}.manifest.json" +RULES_FILE="${BUNDLE_PATH}/${BUNDLE_ID}.rules.jsonl" +SIGNATURE_FILE="${BUNDLE_PATH}/${BUNDLE_ID}.dsse.json" + +if [[ ! -f "${MANIFEST_FILE}" ]]; then + log_error "Manifest not found: ${MANIFEST_FILE}" + exit 1 +fi + +if [[ ! -f "${RULES_FILE}" ]]; then + log_error "Rules file not found: ${RULES_FILE}" + exit 1 +fi + +# Extract bundle version +BUNDLE_VERSION=$(jq -r '.version // "unknown"' "${MANIFEST_FILE}" 2>/dev/null || echo "unknown") +RULE_COUNT=$(jq -r '.ruleCount // 0' "${MANIFEST_FILE}" 2>/dev/null || echo "0") +SIGNER_KEY_ID=$(jq -r '.signerKeyId // "unknown"' "${MANIFEST_FILE}" 2>/dev/null || echo "unknown") + +log_info "Bundle version: ${BUNDLE_VERSION}" +log_info "Rule count: ${RULE_COUNT}" +log_info "Signer key ID: ${SIGNER_KEY_ID}" + +# Verify signature if required +if [[ "${REQUIRE_SIGNATURE}" == "true" ]]; then + log_info "Verifying bundle signature..." + + if [[ ! -f "${SIGNATURE_FILE}" ]]; then + log_error "Signature file not found: ${SIGNATURE_FILE}" + log_error "Set REQUIRE_SIGNATURE=false to skip signature verification (not recommended)" + exit 1 + fi + + # Set attestor mirror URL if provided + if [[ -n "${ATTESTOR_MIRROR}" ]]; then + export STELLA_ATTESTOR_URL="file://${ATTESTOR_MIRROR}" + log_info "Using attestor mirror: ${STELLA_ATTESTOR_URL}" + fi + + # Verify using stella CLI if available + if command -v stella &>/dev/null; then + if ! stella secrets bundle verify --bundle "${BUNDLE_PATH}" --bundle-id "${BUNDLE_ID}"; then + log_error "Bundle signature verification failed" + exit 1 + fi + log_info "Signature verification passed" + else + log_warn "stella CLI not found, performing basic signature file check only" + + # Basic check: verify signature file is valid JSON with expected structure + if ! jq -e '.payloadType and .payload and .signatures' "${SIGNATURE_FILE}" >/dev/null 2>&1; then + log_error "Invalid DSSE envelope structure in ${SIGNATURE_FILE}" + exit 1 + fi + + # Verify payload digest matches + EXPECTED_DIGEST=$(jq -r '.payload' "${SIGNATURE_FILE}" | base64 -d | sha256sum | cut -d' ' -f1) + ACTUAL_DIGEST=$(sha256sum "${MANIFEST_FILE}" | cut -d' ' -f1) + + if [[ "${EXPECTED_DIGEST}" != "${ACTUAL_DIGEST}" ]]; then + log_error "Payload digest mismatch" + log_error "Expected: ${EXPECTED_DIGEST}" + log_error "Actual: ${ACTUAL_DIGEST}" + exit 1 + fi + + log_warn "Basic signature structure verified (full cryptographic verification requires stella CLI)" + fi +else + log_warn "Signature verification skipped (REQUIRE_SIGNATURE=false)" +fi + +# Verify file digests listed in manifest +log_info "Verifying file digests..." +DIGEST_ERRORS=() + +while IFS= read -r file_entry; do + FILE_NAME=$(echo "${file_entry}" | jq -r '.name') + EXPECTED_DIGEST=$(echo "${file_entry}" | jq -r '.digest' | sed 's/sha256://') + FILE_PATH="${BUNDLE_PATH}/${FILE_NAME}" + + if [[ ! -f "${FILE_PATH}" ]]; then + DIGEST_ERRORS+=("File missing: ${FILE_NAME}") + continue + fi + + ACTUAL_DIGEST=$(sha256sum "${FILE_PATH}" | cut -d' ' -f1) + if [[ "${EXPECTED_DIGEST}" != "${ACTUAL_DIGEST}" ]]; then + DIGEST_ERRORS+=("Digest mismatch: ${FILE_NAME}") + fi +done < <(jq -c '.files[]' "${MANIFEST_FILE}" 2>/dev/null) + +if [[ ${#DIGEST_ERRORS[@]} -gt 0 ]]; then + log_error "File digest verification failed:" + for err in "${DIGEST_ERRORS[@]}"; do + log_error " - ${err}" + done + exit 1 +fi +log_info "File digests verified" + +# Check existing installation +if [[ -d "${INSTALL_PATH}" ]]; then + EXISTING_MANIFEST="${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" + if [[ -f "${EXISTING_MANIFEST}" ]]; then + EXISTING_VERSION=$(jq -r '.version // "unknown"' "${EXISTING_MANIFEST}" 2>/dev/null || echo "unknown") + log_info "Existing installation found: version ${EXISTING_VERSION}" + + # Version comparison (CalVer: YYYY.MM) + if [[ "${EXISTING_VERSION}" > "${BUNDLE_VERSION}" ]]; then + log_warn "Existing version (${EXISTING_VERSION}) is newer than bundle (${BUNDLE_VERSION})" + log_warn "Use FORCE_INSTALL=true to override" + if [[ "${FORCE_INSTALL:-false}" != "true" ]]; then + exit 1 + fi + fi + fi +fi + +# Create installation directory +log_info "Creating installation directory: ${INSTALL_PATH}" +mkdir -p "${INSTALL_PATH}" + +# Install bundle files +log_info "Installing bundle files..." +for file in "${BUNDLE_PATH}"/${BUNDLE_ID}.*; do + if [[ -f "${file}" ]]; then + FILE_NAME=$(basename "${file}") + echo " ${FILE_NAME}" + cp -f "${file}" "${INSTALL_PATH}/" + fi +done + +# Set permissions +log_info "Setting file permissions..." +chmod 640 "${INSTALL_PATH}"/${BUNDLE_ID}.* 2>/dev/null || true + +# Set ownership if running as root +if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then + if id "${STELLAOPS_USER}" &>/dev/null; then + chown "${STELLAOPS_USER}:${STELLAOPS_GROUP}" "${INSTALL_PATH}"/${BUNDLE_ID}.* 2>/dev/null || true + log_info "Set ownership to ${STELLAOPS_USER}:${STELLAOPS_GROUP}" + else + log_warn "User ${STELLAOPS_USER} does not exist, skipping ownership change" + fi +else + log_info "Not running as root, skipping ownership change" +fi + +# Create installation receipt +RECEIPT_FILE="${INSTALL_PATH}/.install-receipt.json" +cat > "${RECEIPT_FILE}" </dev/null || hostname)" +} +EOF + +# Verify installation +INSTALLED_VERSION=$(jq -r '.version' "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" 2>/dev/null || echo "unknown") +log_info "Successfully installed secrets bundle version ${INSTALLED_VERSION}" + +echo "" +echo "Installation summary:" +echo " Bundle ID: ${BUNDLE_ID}" +echo " Version: ${INSTALLED_VERSION}" +echo " Rule count: ${RULE_COUNT}" +echo " Install path: ${INSTALL_PATH}" +echo " Receipt: ${RECEIPT_FILE}" +echo "" +echo "Next steps:" +echo " 1. Restart Scanner Worker to load the new bundle:" +echo " systemctl restart stellaops-scanner-worker" +echo "" +echo " Or with Kubernetes:" +echo " kubectl rollout restart deployment/scanner-worker -n stellaops" +echo "" +echo " 2. Verify bundle is loaded:" +echo " kubectl logs -l app=scanner-worker --tail=100 | grep SecretsAnalyzerHost" diff --git a/devops/offline/scripts/rotate-secrets-bundle.sh b/devops/offline/scripts/rotate-secrets-bundle.sh new file mode 100644 index 000000000..693cb0c99 --- /dev/null +++ b/devops/offline/scripts/rotate-secrets-bundle.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# rotate-secrets-bundle.sh +# Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) +# Task: OKS-006 - Add bundle rotation/upgrade workflow +# Description: Safely rotate/upgrade secrets rule bundle with backup and rollback +# ----------------------------------------------------------------------------- +# Usage: ./rotate-secrets-bundle.sh [install-path] +# Example: ./rotate-secrets-bundle.sh /mnt/offline-kit/rules/secrets/2026.02 + +set -euo pipefail + +# Configuration +NEW_BUNDLE_PATH="${1:?New bundle path required (e.g., /mnt/offline-kit/rules/secrets/2026.02)}" +INSTALL_PATH="${2:-/opt/stellaops/plugins/scanner/analyzers/secrets}" +BACKUP_BASE="${BACKUP_BASE:-/opt/stellaops/backups/secrets-bundles}" +BUNDLE_ID="${BUNDLE_ID:-secrets.ruleset}" +ATTESTOR_MIRROR="${ATTESTOR_MIRROR:-}" +RESTART_WORKERS="${RESTART_WORKERS:-true}" +KUBERNETES_NAMESPACE="${KUBERNETES_NAMESPACE:-stellaops}" +KUBERNETES_DEPLOYMENT="${KUBERNETES_DEPLOYMENT:-scanner-worker}" +MAX_BACKUPS="${MAX_BACKUPS:-5}" + +# Script directory for calling install script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Color output helpers +if [[ -t 1 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + NC='' +fi + +log_info() { echo -e "${GREEN}==>${NC} $*"; } +log_warn() { echo -e "${YELLOW}WARN:${NC} $*" >&2; } +log_error() { echo -e "${RED}ERROR:${NC} $*" >&2; } +log_step() { echo -e "${BLUE}--->${NC} $*"; } + +# Error handler +cleanup_on_error() { + log_error "Rotation failed! Attempting rollback..." + if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR}" ]]; then + perform_rollback "${BACKUP_DIR}" + fi +} + +perform_rollback() { + local backup_dir="$1" + log_info "Rolling back to backup: ${backup_dir}" + + if [[ ! -d "${backup_dir}" ]]; then + log_error "Backup directory not found: ${backup_dir}" + return 1 + fi + + # Restore files + cp -a "${backup_dir}"/* "${INSTALL_PATH}/" 2>/dev/null || { + log_error "Failed to restore files from backup" + return 1 + } + + log_info "Rollback completed" + + # Restart workers after rollback + if [[ "${RESTART_WORKERS}" == "true" ]]; then + restart_workers "rollback" + fi + + return 0 +} + +restart_workers() { + local reason="${1:-upgrade}" + log_info "Restarting scanner workers (${reason})..." + + # Try Kubernetes first + if command -v kubectl &>/dev/null; then + if kubectl get deployment "${KUBERNETES_DEPLOYMENT}" -n "${KUBERNETES_NAMESPACE}" &>/dev/null; then + log_step "Performing Kubernetes rolling restart..." + kubectl rollout restart deployment/"${KUBERNETES_DEPLOYMENT}" -n "${KUBERNETES_NAMESPACE}" + log_step "Waiting for rollout to complete..." + kubectl rollout status deployment/"${KUBERNETES_DEPLOYMENT}" -n "${KUBERNETES_NAMESPACE}" --timeout=300s || { + log_warn "Rollout status check timed out (workers may still be restarting)" + } + return 0 + fi + fi + + # Try systemd + if command -v systemctl &>/dev/null; then + if systemctl is-active stellaops-scanner-worker &>/dev/null 2>&1; then + log_step "Restarting systemd service..." + systemctl restart stellaops-scanner-worker + return 0 + fi + fi + + log_warn "Could not auto-restart workers (no Kubernetes or systemd found)" + log_warn "Please restart scanner workers manually" +} + +cleanup_old_backups() { + log_info "Cleaning up old backups (keeping last ${MAX_BACKUPS})..." + + if [[ ! -d "${BACKUP_BASE}" ]]; then + return 0 + fi + + # List backups sorted by name (which includes timestamp) + local backups + backups=$(find "${BACKUP_BASE}" -maxdepth 1 -type d -name "20*" | sort -r) + local count=0 + + for backup in ${backups}; do + count=$((count + 1)) + if [[ ${count} -gt ${MAX_BACKUPS} ]]; then + log_step "Removing old backup: ${backup}" + rm -rf "${backup}" + fi + done +} + +# Main rotation logic +main() { + echo "" + log_info "Secrets Bundle Rotation" + echo "========================================" + echo "" + + # Validate new bundle + log_info "Step 1/6: Validating new bundle..." + if [[ ! -d "${NEW_BUNDLE_PATH}" ]]; then + log_error "New bundle directory not found: ${NEW_BUNDLE_PATH}" + exit 1 + fi + + NEW_MANIFEST="${NEW_BUNDLE_PATH}/${BUNDLE_ID}.manifest.json" + if [[ ! -f "${NEW_MANIFEST}" ]]; then + log_error "New bundle manifest not found: ${NEW_MANIFEST}" + exit 1 + fi + + NEW_VERSION=$(jq -r '.version // "unknown"' "${NEW_MANIFEST}" 2>/dev/null || echo "unknown") + NEW_RULE_COUNT=$(jq -r '.ruleCount // 0' "${NEW_MANIFEST}" 2>/dev/null || echo "0") + log_step "New version: ${NEW_VERSION} (${NEW_RULE_COUNT} rules)" + + # Check current installation + log_info "Step 2/6: Checking current installation..." + CURRENT_VERSION="(none)" + CURRENT_RULE_COUNT="0" + + if [[ -f "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" ]]; then + CURRENT_VERSION=$(jq -r '.version // "unknown"' "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" 2>/dev/null || echo "unknown") + CURRENT_RULE_COUNT=$(jq -r '.ruleCount // 0' "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" 2>/dev/null || echo "0") + log_step "Current version: ${CURRENT_VERSION} (${CURRENT_RULE_COUNT} rules)" + else + log_step "No current installation found" + fi + + # Version comparison + if [[ "${CURRENT_VERSION}" != "(none)" ]]; then + if [[ "${CURRENT_VERSION}" == "${NEW_VERSION}" ]]; then + log_warn "New version (${NEW_VERSION}) is the same as current" + if [[ "${FORCE_ROTATION:-false}" != "true" ]]; then + log_warn "Use FORCE_ROTATION=true to reinstall" + exit 0 + fi + elif [[ "${CURRENT_VERSION}" > "${NEW_VERSION}" ]]; then + log_warn "New version (${NEW_VERSION}) is older than current (${CURRENT_VERSION})" + if [[ "${FORCE_ROTATION:-false}" != "true" ]]; then + log_warn "Use FORCE_ROTATION=true to downgrade" + exit 1 + fi + fi + fi + + echo "" + log_info "Upgrade: ${CURRENT_VERSION} -> ${NEW_VERSION}" + echo "" + + # Backup current installation + log_info "Step 3/6: Creating backup..." + BACKUP_DIR="${BACKUP_BASE}/$(date +%Y%m%d_%H%M%S)_${CURRENT_VERSION}" + + if [[ -d "${INSTALL_PATH}" && -f "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" ]]; then + mkdir -p "${BACKUP_DIR}" + cp -a "${INSTALL_PATH}"/* "${BACKUP_DIR}/" 2>/dev/null || { + log_error "Failed to create backup" + exit 1 + } + log_step "Backup created: ${BACKUP_DIR}" + + # Create backup metadata + cat > "${BACKUP_DIR}/.backup-metadata.json" </dev/null || hostname)" +} +EOF + else + log_step "No existing installation to backup" + BACKUP_DIR="" + fi + + # Set up error handler for rollback + trap cleanup_on_error ERR + + # Install new bundle + log_info "Step 4/6: Installing new bundle..." + export FORCE_INSTALL=true + export REQUIRE_SIGNATURE="${REQUIRE_SIGNATURE:-true}" + + if [[ -n "${ATTESTOR_MIRROR}" ]]; then + "${SCRIPT_DIR}/install-secrets-bundle.sh" "${NEW_BUNDLE_PATH}" "${INSTALL_PATH}" "${ATTESTOR_MIRROR}" + else + "${SCRIPT_DIR}/install-secrets-bundle.sh" "${NEW_BUNDLE_PATH}" "${INSTALL_PATH}" + fi + + # Verify installation + log_info "Step 5/6: Verifying installation..." + INSTALLED_VERSION=$(jq -r '.version' "${INSTALL_PATH}/${BUNDLE_ID}.manifest.json" 2>/dev/null || echo "unknown") + + if [[ "${INSTALLED_VERSION}" != "${NEW_VERSION}" ]]; then + log_error "Installation verification failed" + log_error "Expected version: ${NEW_VERSION}" + log_error "Installed version: ${INSTALLED_VERSION}" + exit 1 + fi + log_step "Installation verified: ${INSTALLED_VERSION}" + + # Remove error trap since installation succeeded + trap - ERR + + # Restart workers + log_info "Step 6/6: Restarting workers..." + if [[ "${RESTART_WORKERS}" == "true" ]]; then + restart_workers "upgrade" + else + log_step "Worker restart skipped (RESTART_WORKERS=false)" + fi + + # Cleanup old backups + cleanup_old_backups + + # Generate rotation report + REPORT_FILE="${INSTALL_PATH}/.rotation-report.json" + cat > "${REPORT_FILE}" </dev/null || hostname)" +} +EOF + + echo "" + echo "========================================" + log_info "Rotation completed successfully!" + echo "" + echo "Summary:" + echo " Previous version: ${CURRENT_VERSION} (${CURRENT_RULE_COUNT} rules)" + echo " New version: ${NEW_VERSION} (${NEW_RULE_COUNT} rules)" + if [[ -n "${BACKUP_DIR}" ]]; then + echo " Backup path: ${BACKUP_DIR}" + fi + echo " Report: ${REPORT_FILE}" + echo "" + echo "To verify the upgrade:" + echo " kubectl logs -l app=scanner-worker --tail=100 | grep SecretsAnalyzerHost" + echo "" + echo "To rollback if needed:" + echo " $0 --rollback ${BACKUP_DIR:-/path/to/backup}" +} + +# Handle rollback command +if [[ "${1:-}" == "--rollback" ]]; then + ROLLBACK_BACKUP="${2:?Backup directory required for rollback}" + perform_rollback "${ROLLBACK_BACKUP}" + if [[ "${RESTART_WORKERS}" == "true" ]]; then + restart_workers "rollback" + fi + exit 0 +fi + +# Run main +main "$@" diff --git a/docs/24_OFFLINE_KIT.md b/docs/24_OFFLINE_KIT.md index 57f1e993f..6872f1720 100755 --- a/docs/24_OFFLINE_KIT.md +++ b/docs/24_OFFLINE_KIT.md @@ -19,6 +19,7 @@ completely isolated network: | **Delta patches** | Daily diff bundles keep size \< 350 MB | | **Scanner plug-ins** | OS analyzers plus the Node.js, Go, .NET, Python, Ruby, Rust, and PHP language analyzers packaged under `plugins/scanner/analyzers/**` with manifests so Workers load deterministically offline. | | **Debug store** | `.debug` artefacts laid out under `debug/.build-id//.debug` with `debug/debug-manifest.json` mapping build-ids to originating images for symbol retrieval. | +| **Secret Detection Rules** | DSSE-signed rule bundles under `rules/secrets//` with manifest, JSONL rules, and signature envelope for air-gapped secret leak detection. | | **Telemetry collector bundle** | `telemetry/telemetry-offline-bundle.tar.gz` plus `.sha256`, containing OTLP collector config, Helm/Compose overlays, and operator instructions. | | **CLI + Task Packs** | `cli/` binaries from `release/cli`, Task Runner bootstrap (`bootstrap/task-runner/task-runner.yaml.sample`), and task-pack docs under `docs/task-packs/**` + `docs/modules/taskrunner/**`. | | **Orchestrator/Export/Notifier kits** | Orchestrator service, worker SDK, Postgres snapshot, dashboards (`orchestrator/**`), Export Center bundles (`export-center/**`), Notifier offline packs (`notifier/**`). | @@ -41,6 +42,68 @@ completely isolated network: The PHP analyzer parses `composer.lock` for Composer dependencies and supports optional runtime evidence via the `stella-trace.php` shim; set `STELLA_PHP_OPCACHE=1` to enable opcache statistics collection. +**Secret Detection Rules:** + +The Offline Kit includes DSSE-signed rule bundles for secret leak detection, enabling fully offline scanning for exposed credentials, API keys, and other sensitive data. + +**Bundle Structure:** +``` +rules/secrets// + secrets.ruleset.manifest.json # Bundle metadata (version, rule count, signer) + secrets.ruleset.rules.jsonl # Rule definitions (one JSON per line) + secrets.ruleset.dsse.json # DSSE signature envelope + SHA256SUMS # File checksums +``` + +**Manifest Format:** +```json +{ + "bundleId": "secrets.ruleset", + "bundleType": "secrets", + "version": "2026.01", + "ruleCount": 150, + "signerKeyId": "stellaops-secrets-signer", + "signedAt": "2026-01-04T00:00:00Z", + "files": [ + { + "name": "secrets.ruleset.rules.jsonl", + "digest": "sha256:...", + "sizeBytes": 45678 + } + ] +} +``` + +**Installation:** +```bash +# Verify bundle signature using local attestor mirror +export STELLA_ATTESTOR_URL="file:///mnt/offline-kit/attestor-mirror" +devops/offline/scripts/install-secrets-bundle.sh \ + /mnt/offline-kit/rules/secrets/2026.01 \ + /opt/stellaops/plugins/scanner/analyzers/secrets +``` + +**Bundle Rotation:** +```bash +# Upgrade to new version with automatic backup +devops/offline/scripts/rotate-secrets-bundle.sh \ + /mnt/offline-kit/rules/secrets/2026.02 +``` + +**Enable Feature:** +```yaml +scanner: + features: + experimental: + secret-leak-detection: true +``` + +**Verify Bundle is Loaded:** +```bash +kubectl logs -l app=scanner-worker --tail=100 | grep SecretsAnalyzerHost +# Expected: SecretsAnalyzerHost: Loaded bundle 2026.01 with 150 rules +``` + **Python analyzer features:** - **Wheel/sdist/editable** parsing with dependency edges from `METADATA`, `PKG-INFO`, `requirements.txt`, and `pyproject.toml` - **Virtual environment** support for virtualenv, venv, and conda prefix layouts diff --git a/docs/README.md b/docs/README.md index d826568c7..c360d1afa 100755 --- a/docs/README.md +++ b/docs/README.md @@ -13,35 +13,35 @@ This documentation set is internal and does not keep compatibility stubs for old | Goal | Open this | | --- | --- | -| Understand the product in 2 minutes | `overview.md` | -| Run a first scan (CLI) | `quickstart.md` | -| Browse capabilities | `key-features.md` | -| Roadmap (priorities + definition of "done") | `05_ROADMAP.md` | -| Architecture: high-level overview | `40_ARCHITECTURE_OVERVIEW.md` | -| Architecture: full reference map | `07_HIGH_LEVEL_ARCHITECTURE.md` | -| Architecture: user flows (UML) | `technical/architecture/user-flows.md` | -| Architecture: module matrix (46 modules) | `technical/architecture/module-matrix.md` | -| Architecture: data flows | `technical/architecture/data-flows.md` | -| Architecture: schema mapping | `technical/architecture/schema-mapping.md` | -| Offline / air-gap operations | `24_OFFLINE_KIT.md` | -| Security deployment hardening | `17_SECURITY_HARDENING_GUIDE.md` | -| Ingest advisories (Concelier + CLI) | `10_CONCELIER_CLI_QUICKSTART.md` | -| Develop plugins/connectors | `10_PLUGIN_SDK_GUIDE.md` | -| Console (Web UI) operator guide | `15_UI_GUIDE.md` | -| VEX consensus and issuer trust | `16_VEX_CONSENSUS_GUIDE.md` | -| Vulnerability Explorer guide | `20_VULNERABILITY_EXPLORER_GUIDE.md` | +| Understand the product in 2 minutes | [overview.md](/docs/overview/) | +| Run a first scan (CLI) | [quickstart.md](/docs/quickstart/) | +| Browse capabilities | [key-features.md](/docs/key-features/) | +| Roadmap (priorities + definition of "done") | [05_ROADMAP.md](/docs/05_roadmap/) | +| Architecture: high-level overview | [40_ARCHITECTURE_OVERVIEW.md](/docs/40_architecture_overview/) | +| Architecture: full reference map | [07_HIGH_LEVEL_ARCHITECTURE.md](/docs/07_high_level_architecture/) | +| Architecture: user flows (UML) | [technical/architecture/user-flows.md](/docs/technical/architecture/user-flows/) | +| Architecture: module matrix (46 modules) | [technical/architecture/module-matrix.md](/docs/technical/architecture/module-matrix/) | +| Architecture: data flows | [technical/architecture/data-flows.md](/docs/technical/architecture/data-flows/) | +| Architecture: schema mapping | [technical/architecture/schema-mapping.md](/docs/technical/architecture/schema-mapping/) | +| Offline / air-gap operations | [24_OFFLINE_KIT.md](/docs/24_offline_kit/) | +| Security deployment hardening | [17_SECURITY_HARDENING_GUIDE.md](/docs/17_security_hardening_guide/) | +| Ingest advisories (Concelier + CLI) | [10_CONCELIER_CLI_QUICKSTART.md](/docs/10_concelier_cli_quickstart/) | +| Develop plugins/connectors | [10_PLUGIN_SDK_GUIDE.md](/docs/10_plugin_sdk_guide/) | +| Console (Web UI) operator guide | [15_UI_GUIDE.md](/docs/15_ui_guide/) | +| VEX consensus and issuer trust | [16_VEX_CONSENSUS_GUIDE.md](/docs/16_vex_consensus_guide/) | +| Vulnerability Explorer guide | [20_VULNERABILITY_EXPLORER_GUIDE.md](/docs/20_vulnerability_explorer_guide/) | ## Detailed Indexes -- **Technical index (everything):** `docs/technical/README.md` -- **End-to-end workflow flows:** `docs/flows/` (16 detailed flow documents) -- **Module dossiers:** `docs/modules/` -- **API contracts and samples:** `docs/api/` -- **Architecture notes / ADRs:** `docs/architecture/`, `docs/adr/` -- **Operations and deployment:** `docs/operations/`, `docs/deploy/`, `docs/deployment/` -- **Air-gap workflows:** `docs/airgap/` -- **Security deep dives:** `docs/security/` -- **Benchmarks and fixtures:** `docs/benchmarks/`, `docs/assets/` +- **Technical index (everything):** [docs/technical/README.md](/docs/technical/) +- **End-to-end workflow flows:** [docs/flows/](/docs/flows/) (16 detailed flow documents) +- **Module dossiers:** [docs/modules/](/docs/modules/) +- **API contracts and samples:** [docs/api/](/docs/api/) +- **Architecture notes / ADRs:** [docs/architecture/](/docs/architecture/), [docs/adr/](/docs/adr/) +- **Operations and deployment:** [docs/operations/](/docs/operations/), [docs/deploy/](/docs/deploy/), [docs/deployment/](/docs/deployment/) +- **Air-gap workflows:** [docs/airgap/](/docs/airgap/) +- **Security deep dives:** [docs/security/](/docs/security/) +- **Benchmarks and fixtures:** [docs/benchmarks/](/docs/benchmarks/), [docs/assets/](/docs/assets/) ## Notes diff --git a/docs/implplan/SPRINT_20260104_001_BE_determinism_timeprovider_injection.md b/docs/implplan/SPRINT_20260104_001_BE_determinism_timeprovider_injection.md index d63be6403..2b7fae6e3 100644 --- a/docs/implplan/SPRINT_20260104_001_BE_determinism_timeprovider_injection.md +++ b/docs/implplan/SPRINT_20260104_001_BE_determinism_timeprovider_injection.md @@ -64,13 +64,13 @@ | 8 | DET-008 | DONE | DET-002, DET-003 | Guild | Refactor Registry module (1 file: RegistryTokenIssuer) | | 9 | DET-009 | DONE | DET-002, DET-003 | Guild | Refactor Replay module (6 files: ReplayEngine, ReplayModels, ReplayExportModels, ReplayManifestExporter, FeedSnapshotCoordinatorService, PolicySimulationInputLock) | | 10 | DET-010 | DONE | DET-002, DET-003 | Guild | Refactor RiskEngine module (skipped - no determinism issues found) | -| 11 | DET-011 | TODO | DET-002, DET-003 | Guild | Refactor Scanner module (~45+ matches remaining) | +| 11 | DET-011 | DOING | DET-002, DET-003 | Guild | Refactor Scanner module - Explainability (2 files: RiskReport, FalsifiabilityGenerator), Sources (5 files: ConnectionTesters, SourceConnectionTester, SourceTriggerDispatcher), VulnSurfaces (1 file: PostgresVulnSurfaceRepository), Storage (5 files: PostgresProofSpineRepository, PostgresScanMetricsRepository, RuntimeEventRepository, PostgresFuncProofRepository, PostgresIdempotencyKeyRepository), Storage.Oci (1 file: SlicePullService) | | 12 | DET-012 | DONE | DET-002, DET-003 | Guild | Refactor Scheduler module (WebService, Persistence, Worker projects - 30+ files updated, tests migrated to FakeTimeProvider) | -| 13 | DET-013 | TODO | DET-002, DET-003 | Guild | Refactor Signer module (~89 matches remaining) | +| 13 | DET-013 | DONE | DET-002, DET-003 | Guild | Refactor Signer module (16 production files refactored: AmbientOidcTokenProvider, EphemeralKeyPair, IOidcTokenProvider, IFulcioClient, TrustAnchorManager, KeyRotationService, DefaultSigningKeyResolver, SigstoreSigningService, InMemorySignerAuditSink, KeyRotationEndpoints, Program.cs) | | 14 | DET-014 | DONE | DET-002, DET-003 | Guild | Refactor Unknowns module (skipped - no determinism issues found) | -| 15 | DET-015 | TODO | DET-002, DET-003 | Guild | Refactor VexLens module (~76 matches remaining) | +| 15 | DET-015 | DONE | DET-002, DET-003 | Guild | Refactor VexLens module (production files: IConsensusRationaleCache, InMemorySourceTrustScoreCache, ISourceTrustScoreCalculator, InMemoryIssuerDirectory, InMemoryConsensusProjectionStore, OpenVexNormalizer, CycloneDxVexNormalizer, CsafVexNormalizer, IConsensusJobService, VexProofBuilder, IConsensusExportService, IVexLensApiService, TrustScorecardApiModels, OrchestratorLedgerEventEmitter, PostgresConsensusProjectionStore, PostgresConsensusProjectionStoreProxy, ProvenanceChainValidator, VexConsensusEngine, IConsensusRationaleService, VexLensEndpointExtensions) | | 16 | DET-016 | DONE | DET-002, DET-003 | Guild | Refactor VulnExplorer module (1 file: VexDecisionStore) | -| 17 | DET-017 | TODO | DET-002, DET-003 | Guild | Refactor Zastava module (~48 matches remaining) | +| 17 | DET-017 | DONE | DET-002, DET-003 | Guild | Refactor Zastava module (~48 matches remaining) | | 18 | DET-018 | TODO | DET-004 to DET-017 | Guild | Final audit: verify zero direct DateTime/Guid/Random calls in production code | ## Implementation Pattern @@ -123,6 +123,12 @@ services.AddSingleton(); | 2026-01-05 | DET-005: Provcache module refactored - 8 files (EvidenceChunker, LazyFetchOrchestrator, MinimalProofExporter, FeedEpochAdvancedEvent, SignerRevokedEvent, Postgres repos, ValkeyProvcacheStore) | Agent | | 2026-01-05 | DET-006 to DET-010: Batch completed - ReachGraph (1 file), Registry (1 file), Replay (6 files); Provenance, RiskEngine, Unknowns already clean | Agent | | 2026-01-05 | Remaining modules assessed: Scanner (~45), Scheduler (~20), Signer (~89), VexLens (~76), VulnExplorer (3), Zastava (~48) matches | Agent | +| 2026-01-05 | DET-012 complete: Scheduler module refactored - WebService, Persistence, Worker projects (30+ files) | Agent | +| 2026-01-05 | DET-013 complete: Signer module refactored - Keyless (4 files: AmbientOidcTokenProvider, EphemeralKeyPair, IOidcTokenProvider, IFulcioClient with IsExpiredAt/IsValidAt methods), KeyManagement (2 files: TrustAnchorManager, KeyRotationService), Infrastructure (3 files: DefaultSigningKeyResolver, SigstoreSigningService, InMemorySignerAuditSink), WebService (2 files: Program.cs, KeyRotationEndpoints) | Agent | + +| 2026-01-05 | DET-015 complete: VexLens module refactored - 20 production files (caching, storage, normalization, orchestration, API, consensus, trust, persistence) with TimeProvider and IGuidProvider injection. Note: Pre-existing build errors in NoiseGateService.cs and NoiseGatingApiModels.cs unrelated to determinism changes. | Agent | +| 2026-01-05 | DET-017 complete: Zastava module refactored - Agent (RuntimeEventsClient, HealthCheckHostedService, RuntimeEventDispatchService, RuntimeEventBuffer), Observer (RuntimeEventDispatchService, RuntimeEventBuffer, ProcSnapshotCollector, EbpfProbeManager), Webhook (WebhookCertificateHealthCheck) with TimeProvider and IGuidProvider injection. | Agent | +| 2026-01-05 | DET-011 in progress: Scanner module refactoring - 14 production files refactored (RiskReport.cs, FalsifiabilityGenerator.cs, SourceConnectionTester.cs, SourceTriggerDispatcher.cs, DockerConnectionTester.cs, ZastavaConnectionTester.cs, GitConnectionTester.cs, PostgresVulnSurfaceRepository.cs, PostgresProofSpineRepository.cs, PostgresScanMetricsRepository.cs, RuntimeEventRepository.cs, PostgresFuncProofRepository.cs, PostgresIdempotencyKeyRepository.cs, SlicePullService.cs). Added Determinism.Abstractions references to 4 Scanner sub-projects. | Agent | ## Decisions & Risks - **Decision:** Defer determinism refactoring from MAINT audit to dedicated sprint for focused, systematic approach. diff --git a/docs/implplan/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md b/docs/implplan/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md index 7f815c5d2..398fa3eb2 100644 --- a/docs/implplan/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md +++ b/docs/implplan/SPRINT_20260104_005_AIRGAP_secret_offline_kit.md @@ -30,16 +30,16 @@ Integrate secret detection rule bundles with the Offline Kit infrastructure for | # | Task ID | Status | Key dependency / next step | Owners | Task Definition | | --- | --- | --- | --- | --- | --- | -| 1 | OKS-001 | TODO | None | AirGap Guild | Update Offline Kit manifest schema for rules | -| 2 | OKS-002 | TODO | OKS-001 | AirGap Guild | Add secrets bundle to BundleBuilder | -| 3 | OKS-003 | TODO | OKS-002 | AirGap Guild | Create bundle verification in Importer | -| 4 | OKS-004 | TODO | None | AirGap Guild | Add Attestor mirror support for bundle verification | -| 5 | OKS-005 | TODO | OKS-003 | AirGap Guild | Create bundle installation script | -| 6 | OKS-006 | TODO | OKS-005 | AirGap Guild | Add bundle rotation/upgrade workflow | -| 7 | OKS-007 | TODO | None | CI/CD Guild | Add bundle to release workflow | -| 8 | OKS-008 | TODO | All | AirGap Guild | Add integration tests for offline flow | -| 9 | OKS-009 | TODO | All | Docs Guild | Update offline kit documentation | -| 10 | OKS-010 | TODO | All | DevOps Guild | Update Helm charts for bundle mounting | +| 1 | OKS-001 | DONE | None | AirGap Guild | Update Offline Kit manifest schema for rules | +| 2 | OKS-002 | DONE | OKS-001 | AirGap Guild | Add secrets bundle to BundleBuilder | +| 3 | OKS-003 | DONE | OKS-002 | AirGap Guild | Create bundle verification in Importer | +| 4 | OKS-004 | DONE | None | AirGap Guild | Add Attestor mirror support for bundle verification | +| 5 | OKS-005 | DONE | OKS-003 | AirGap Guild | Create bundle installation script | +| 6 | OKS-006 | DONE | OKS-005 | AirGap Guild | Add bundle rotation/upgrade workflow | +| 7 | OKS-007 | DONE | None | CI/CD Guild | Add bundle to release workflow | +| 8 | OKS-008 | DONE | All | AirGap Guild | Add integration tests for offline flow | +| 9 | OKS-009 | DONE | All | Docs Guild | Update offline kit documentation | +| 10 | OKS-010 | DONE | All | DevOps Guild | Update Helm charts for bundle mounting | ## Task Details @@ -588,4 +588,17 @@ devops/offline/ | Date | Action | Notes | |------|--------|-------| | 2026-01-04 | Sprint created | Part of secret leak detection implementation | +| 2026-01-04 | OKS-001 DONE | Added RuleBundleComponent to OfflineKitManifest.cs with rules schema | +| 2026-01-04 | OKS-002 DONE | Extended SnapshotBundleWriter/Reader, added RulesSnapshotExtractor | +| 2026-01-04 | OKS-003 DONE | Created RuleBundleValidator with digest/signature/monotonicity checks | +| 2026-01-04 | OKS-004 DONE | Added RuleBundleSigningPath to FileSystemRootStore, DsseVerifier support | +| 2026-01-04 | OKS-005 DONE | Created devops/offline/scripts/install-secrets-bundle.sh | +| 2026-01-04 | OKS-006 DONE | Created devops/offline/scripts/rotate-secrets-bundle.sh with rollback | +| 2026-01-04 | OKS-007 DONE | Created .gitea/workflows/secrets-bundle-release.yml CI/CD workflow | +| 2026-01-04 | OKS-008 DONE | Added RuleBundleValidatorTests.cs with 8 test cases | +| 2026-01-04 | OKS-009 DONE | Updated docs/24_OFFLINE_KIT.md with secrets bundle documentation | +| 2026-01-04 | OKS-010 DONE | Updated values-airgap.yaml with secrets-rules volume mount and PVC | +| 2026-01-04 | Fix build errors | Fixed 4 nullability errors in OfflineVerificationPolicy.cs, JsonNormalizer.cs, SbomNormalizer.cs | +| 2026-01-04 | Fix test versions | Updated RuleBundleValidatorTests to use 3-part semver (2026.1.0) instead of CalVer | +| 2026-01-04 | Sprint complete | All 10 tasks completed, build passes, tests pass (9/9) | diff --git a/docs/implplan/SPRINT_20260104_006_BE_secret_detection_config_api.md b/docs/implplan/SPRINT_20260104_006_BE_secret_detection_config_api.md new file mode 100644 index 000000000..e36e32fd2 --- /dev/null +++ b/docs/implplan/SPRINT_20260104_006_BE_secret_detection_config_api.md @@ -0,0 +1,213 @@ +# Sprint 20260104_006_BE - Secret Detection Configuration API + +## Topic & Scope + +Backend APIs and data models for configuring secret detection behavior per tenant. This sprint provides the foundation for UI configuration of secret leak detection. + +**Key deliverables:** +1. **Tenant Settings Model**: Per-tenant secret detection configuration +2. **Revelation Policy**: Control how detected secrets are displayed/masked +3. **Exception Management**: Allowlist patterns for false positives +4. **Configuration API**: CRUD endpoints for settings + +**Working directory:** `src/Scanner/`, `src/Platform/` + +## Dependencies & Concurrency + +- **Depends on**: Sprint 20260104_001 (Core Analyzer), Sprint 20260104_002 (Rule Bundles) +- **Parallel with**: Sprint 20260104_007 (Alert Integration) +- **Blocks**: Sprint 20260104_008 (UI) + +## Documentation Prerequisites + +- docs/modules/scanner/operations/secret-leak-detection.md +- CLAUDE.md Section 8 (Determinism) + +## Delivery Tracker + +| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | +| --- | --- | --- | --- | --- | --- | +| 1 | SDC-001 | TODO | None | Scanner Guild | Define SecretDetectionSettings domain model | +| 2 | SDC-002 | TODO | SDC-001 | Scanner Guild | Create SecretRevelationPolicy enum and config | +| 3 | SDC-003 | TODO | SDC-001 | Scanner Guild | Create SecretExceptionPattern model for allowlists | +| 4 | SDC-004 | TODO | SDC-001 | Platform Guild | Add persistence (EF Core migrations) | +| 5 | SDC-005 | TODO | SDC-004 | Platform Guild | Create Settings CRUD API endpoints | +| 6 | SDC-006 | TODO | SDC-005 | Platform Guild | Add OpenAPI spec for settings endpoints | +| 7 | SDC-007 | TODO | SDC-003 | Scanner Guild | Integrate exception patterns into SecretsAnalyzerHost | +| 8 | SDC-008 | TODO | SDC-002 | Scanner Guild | Implement revelation policy in findings output | +| 9 | SDC-009 | TODO | All | Scanner Guild | Add unit and integration tests | + +## Task Details + +### SDC-001: SecretDetectionSettings Domain Model + +```csharp +public sealed record SecretDetectionSettings +{ + public required Guid TenantId { get; init; } + public required bool Enabled { get; init; } + public required SecretRevelationPolicy RevelationPolicy { get; init; } + public required IReadOnlyList EnabledRuleCategories { get; init; } + public required IReadOnlyList Exceptions { get; init; } + public required SecretAlertSettings AlertSettings { get; init; } + public required DateTimeOffset UpdatedAt { get; init; } + public required string UpdatedBy { get; init; } +} +``` + +Location: `src/Scanner/__Libraries/StellaOps.Scanner.Core/Secrets/Configuration/` + +### SDC-002: SecretRevelationPolicy + +Control how detected secrets appear in different contexts: + +```csharp +public enum SecretRevelationPolicy +{ + /// + /// Show only that a secret was detected, no value shown. + /// Example: [SECRET_DETECTED: aws_access_key_id] + /// + FullMask = 0, + + /// + /// Show first and last 4 characters. + /// Example: AKIA****WXYZ + /// + PartialReveal = 1, + + /// + /// Show full value (requires elevated permissions). + /// Use only for debugging/incident response. + /// + FullReveal = 2 +} + +public sealed record RevelationPolicyConfig +{ + /// Default policy for UI/API responses. + public SecretRevelationPolicy DefaultPolicy { get; init; } = SecretRevelationPolicy.PartialReveal; + + /// Policy for exported reports (PDF, JSON). + public SecretRevelationPolicy ExportPolicy { get; init; } = SecretRevelationPolicy.FullMask; + + /// Policy for logs and telemetry. + public SecretRevelationPolicy LogPolicy { get; init; } = SecretRevelationPolicy.FullMask; + + /// Roles allowed to use FullReveal. + public IReadOnlyList FullRevealRoles { get; init; } = ["security-admin", "incident-responder"]; + + /// Number of characters to show at start/end for PartialReveal. + public int PartialRevealChars { get; init; } = 4; +} +``` + +### SDC-003: SecretExceptionPattern (Allowlist) + +```csharp +public sealed record SecretExceptionPattern +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public required string Description { get; init; } + + /// Regex pattern to match against detected secret value. + public required string Pattern { get; init; } + + /// Optional: Only apply to specific rule IDs. + public IReadOnlyList? ApplicableRuleIds { get; init; } + + /// Optional: Only apply to specific file paths. + public string? FilePathGlob { get; init; } + + /// Reason for exception (audit trail). + public required string Justification { get; init; } + + /// Expiration date (null = permanent). + public DateTimeOffset? ExpiresAt { get; init; } + + public required DateTimeOffset CreatedAt { get; init; } + public required string CreatedBy { get; init; } +} +``` + +### SDC-005: Settings API Endpoints + +``` +GET /api/v1/tenants/{tenantId}/settings/secret-detection +PUT /api/v1/tenants/{tenantId}/settings/secret-detection +PATCH /api/v1/tenants/{tenantId}/settings/secret-detection + +GET /api/v1/tenants/{tenantId}/settings/secret-detection/exceptions +POST /api/v1/tenants/{tenantId}/settings/secret-detection/exceptions +DELETE /api/v1/tenants/{tenantId}/settings/secret-detection/exceptions/{exceptionId} + +GET /api/v1/tenants/{tenantId}/settings/secret-detection/rule-categories +``` + +### SDC-008: Revelation Policy Implementation + +```csharp +public static class SecretMasker +{ + public static string Mask(string secretValue, SecretRevelationPolicy policy, int partialChars = 4) + { + return policy switch + { + SecretRevelationPolicy.FullMask => "[REDACTED]", + SecretRevelationPolicy.PartialReveal => MaskPartial(secretValue, partialChars), + SecretRevelationPolicy.FullReveal => secretValue, + _ => "[REDACTED]" + }; + } + + private static string MaskPartial(string value, int chars) + { + if (value.Length <= chars * 2) + return new string('*', value.Length); + + var prefix = value[..chars]; + var suffix = value[^chars..]; + var masked = new string('*', Math.Min(value.Length - chars * 2, 8)); + return $"{prefix}{masked}{suffix}"; + } +} +``` + +## Directory Structure + +``` +src/Scanner/__Libraries/StellaOps.Scanner.Core/ +├── Secrets/ +│ ├── Configuration/ +│ │ ├── SecretDetectionSettings.cs +│ │ ├── SecretRevelationPolicy.cs +│ │ ├── RevelationPolicyConfig.cs +│ │ ├── SecretExceptionPattern.cs +│ │ └── SecretAlertSettings.cs +│ └── Masking/ +│ └── SecretMasker.cs + +src/Platform/StellaOps.Platform.WebService/ +├── Endpoints/ +│ └── SecretDetectionSettingsEndpoints.cs +└── Persistence/ + └── Migrations/ + └── AddSecretDetectionSettings.cs +``` + +## Decisions & Risks + +| Decision | Rationale | +|----------|-----------| +| Per-tenant settings | Multi-tenant isolation requirement | +| Role-based full reveal | Security: prevent accidental exposure | +| Exception expiration | Force periodic review of allowlists | +| Separate export/log policies | Defense in depth for sensitive data | + +## Execution Log + +| Date | Action | Notes | +|------|--------|-------| +| 2026-01-04 | Sprint created | Gap identified in secret detection feature | + diff --git a/docs/implplan/SPRINT_20260104_007_BE_secret_detection_alerts.md b/docs/implplan/SPRINT_20260104_007_BE_secret_detection_alerts.md new file mode 100644 index 000000000..3b7a6bc74 --- /dev/null +++ b/docs/implplan/SPRINT_20260104_007_BE_secret_detection_alerts.md @@ -0,0 +1,290 @@ +# Sprint 20260104_007_BE - Secret Detection Alert Integration + +## Topic & Scope + +Integration between secret detection findings and the Notify service for real-time alerting when secrets are discovered in scans. + +**Key deliverables:** +1. **Alert Routing**: Route secret findings to configured channels +2. **Alert Templates**: Formatted notifications for different channels +3. **Rate Limiting**: Prevent alert fatigue from mass findings +4. **Severity Mapping**: Map rule severity to alert priority + +**Working directory:** `src/Scanner/`, `src/Notify/` + +## Dependencies & Concurrency + +- **Depends on**: Sprint 20260104_001 (Core Analyzer), Sprint 20260104_006 (Config API) +- **Parallel with**: Sprint 20260104_008 (UI) +- **Blocks**: Production deployment with alerting + +## Documentation Prerequisites + +- docs/modules/notify/architecture.md +- docs/modules/scanner/operations/secret-leak-detection.md + +## Delivery Tracker + +| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | +| --- | --- | --- | --- | --- | --- | +| 1 | SDA-001 | TODO | None | Scanner Guild | Define SecretAlertSettings model | +| 2 | SDA-002 | TODO | SDA-001 | Scanner Guild | Create SecretFindingAlertEvent | +| 3 | SDA-003 | TODO | SDA-002 | Notify Guild | Add secret-finding alert template | +| 4 | SDA-004 | TODO | SDA-003 | Notify Guild | Implement Slack/Teams formatters | +| 5 | SDA-005 | TODO | SDA-002 | Scanner Guild | Add alert emission to SecretsAnalyzerHost | +| 6 | SDA-006 | TODO | SDA-005 | Scanner Guild | Implement rate limiting / deduplication | +| 7 | SDA-007 | TODO | SDA-006 | Scanner Guild | Add severity-based routing | +| 8 | SDA-008 | TODO | SDA-001 | Platform Guild | Add alert settings to config API | +| 9 | SDA-009 | TODO | All | Scanner Guild | Add integration tests | + +## Task Details + +### SDA-001: SecretAlertSettings Model + +```csharp +public sealed record SecretAlertSettings +{ + /// Enable/disable alerting for this tenant. + public bool Enabled { get; init; } = true; + + /// Minimum severity to trigger alert (Critical, High, Medium, Low). + public SecretSeverity MinimumAlertSeverity { get; init; } = SecretSeverity.High; + + /// Alert destinations by channel type. + public IReadOnlyList Destinations { get; init; } = []; + + /// Rate limit: max alerts per scan. + public int MaxAlertsPerScan { get; init; } = 10; + + /// Deduplication window: don't re-alert same secret within this period. + public TimeSpan DeduplicationWindow { get; init; } = TimeSpan.FromHours(24); + + /// Include file path in alert (may reveal repo structure). + public bool IncludeFilePath { get; init; } = true; + + /// Include masked secret value in alert. + public bool IncludeMaskedValue { get; init; } = true; +} + +public sealed record SecretAlertDestination +{ + public required Guid Id { get; init; } + public required AlertChannelType ChannelType { get; init; } + public required string ChannelId { get; init; } // Slack channel ID, email, webhook URL + public IReadOnlyList? SeverityFilter { get; init; } + public IReadOnlyList? RuleCategoryFilter { get; init; } +} + +public enum AlertChannelType +{ + Slack, + Teams, + Email, + Webhook, + PagerDuty +} +``` + +### SDA-002: SecretFindingAlertEvent + +```csharp +public sealed record SecretFindingAlertEvent +{ + public required Guid EventId { get; init; } + public required Guid TenantId { get; init; } + public required Guid ScanId { get; init; } + public required string ImageRef { get; init; } + + public required SecretSeverity Severity { get; init; } + public required string RuleId { get; init; } + public required string RuleName { get; init; } + public required string RuleCategory { get; init; } + + public required string FilePath { get; init; } + public required int LineNumber { get; init; } + public required string MaskedValue { get; init; } + + public required DateTimeOffset DetectedAt { get; init; } + public required string ScanTriggeredBy { get; init; } + + /// Deduplication key for rate limiting. + public string DeduplicationKey => $"{TenantId}:{RuleId}:{FilePath}:{LineNumber}"; +} +``` + +### SDA-003: Alert Templates + +**Slack Template:** +```json +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🚨 Secret Detected in Container Scan" + } + }, + { + "type": "section", + "fields": [ + { "type": "mrkdwn", "text": "*Severity:*\n{{severity}}" }, + { "type": "mrkdwn", "text": "*Rule:*\n{{ruleName}}" }, + { "type": "mrkdwn", "text": "*Image:*\n`{{imageRef}}`" }, + { "type": "mrkdwn", "text": "*File:*\n`{{filePath}}:{{lineNumber}}`" } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Detected Value:*\n```{{maskedValue}}```" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { "type": "plain_text", "text": "View in StellaOps" }, + "url": "{{findingUrl}}" + }, + { + "type": "button", + "text": { "type": "plain_text", "text": "Add Exception" }, + "url": "{{exceptionUrl}}" + } + ] + } + ] +} +``` + +### SDA-005: Alert Emission in SecretsAnalyzerHost + +```csharp +public sealed class SecretsAnalyzerHost +{ + private readonly ISecretAlertEmitter _alertEmitter; + private readonly ISecretAlertDeduplicator _deduplicator; + + public async Task OnSecretFoundAsync( + SecretFinding finding, + ScanContext context, + CancellationToken ct) + { + var settings = await _settingsProvider.GetAlertSettingsAsync(context.TenantId, ct); + + if (!settings.Enabled) + return; + + if (finding.Severity < settings.MinimumAlertSeverity) + return; + + var alertEvent = MapToAlertEvent(finding, context); + + // Check deduplication + if (await _deduplicator.IsDuplicateAsync(alertEvent, settings.DeduplicationWindow, ct)) + { + _logger.LogDebug("secret.alert.deduplicated key={key}", alertEvent.DeduplicationKey); + return; + } + + // Rate limiting + var alertCount = await _alertEmitter.GetAlertCountForScanAsync(context.ScanId, ct); + if (alertCount >= settings.MaxAlertsPerScan) + { + _logger.LogWarning("secret.alert.rate_limited scan_id={scan_id} count={count}", + context.ScanId, alertCount); + return; + } + + // Emit to configured destinations + await _alertEmitter.EmitAsync(alertEvent, settings.Destinations, ct); + } +} +``` + +### SDA-006: Rate Limiting & Deduplication + +```csharp +public interface ISecretAlertDeduplicator +{ + Task IsDuplicateAsync( + SecretFindingAlertEvent alert, + TimeSpan window, + CancellationToken ct); + + Task RecordAlertAsync( + SecretFindingAlertEvent alert, + CancellationToken ct); +} + +public sealed class ValkeySecretAlertDeduplicator : ISecretAlertDeduplicator +{ + private readonly IValkeyConnection _valkey; + + public async Task IsDuplicateAsync( + SecretFindingAlertEvent alert, + TimeSpan window, + CancellationToken ct) + { + var key = $"secret:alert:dedup:{alert.DeduplicationKey}"; + var exists = await _valkey.ExistsAsync(key); + return exists; + } + + public async Task RecordAlertAsync( + SecretFindingAlertEvent alert, + CancellationToken ct) + { + var key = $"secret:alert:dedup:{alert.DeduplicationKey}"; + await _valkey.SetAsync(key, alert.EventId.ToString(), expiry: TimeSpan.FromHours(24)); + } +} +``` + +## Severity Mapping + +| Rule Severity | Alert Priority | Default Behavior | +|---------------|----------------|------------------| +| Critical | P1 / Immediate | Always alert, page on-call | +| High | P2 / Urgent | Alert to security channel | +| Medium | P3 / Normal | Alert if configured | +| Low | P4 / Info | No alert by default | + +## Directory Structure + +``` +src/Scanner/__Libraries/StellaOps.Scanner.Core/ +├── Secrets/ +│ ├── Alerts/ +│ │ ├── SecretAlertSettings.cs +│ │ ├── SecretFindingAlertEvent.cs +│ │ ├── ISecretAlertEmitter.cs +│ │ ├── ISecretAlertDeduplicator.cs +│ │ └── ValkeySecretAlertDeduplicator.cs + +src/Notify/__Libraries/StellaOps.Notify.Engine/ +├── Templates/ +│ └── SecretFindingAlertTemplate.cs +├── Formatters/ +│ ├── SlackSecretAlertFormatter.cs +│ └── TeamsSecretAlertFormatter.cs +``` + +## Decisions & Risks + +| Decision | Rationale | +|----------|-----------| +| Valkey for deduplication | Fast, distributed, TTL support | +| Per-scan rate limit | Prevent alert storms on large findings | +| Masked values in alerts | Balance security awareness vs exposure | +| Severity-based routing | Different channels for different priorities | + +## Execution Log + +| Date | Action | Notes | +|------|--------|-------| +| 2026-01-04 | Sprint created | Alert integration for secret detection | + diff --git a/docs/implplan/SPRINT_20260104_008_FE_secret_detection_ui.md b/docs/implplan/SPRINT_20260104_008_FE_secret_detection_ui.md new file mode 100644 index 000000000..3af1d7a0d --- /dev/null +++ b/docs/implplan/SPRINT_20260104_008_FE_secret_detection_ui.md @@ -0,0 +1,499 @@ +# Sprint 20260104_008_FE - Secret Detection UI + +## Topic & Scope + +Frontend components for configuring and viewing secret detection findings. Provides tenant administrators with tools to manage detection settings, view findings, and configure alerts. + +**Key deliverables:** +1. **Settings Page**: Configure secret detection for tenant +2. **Findings Viewer**: View detected secrets with proper masking +3. **Exception Manager**: Add/remove allowlist patterns +4. **Alert Configuration**: Set up notification channels + +**Working directory:** `src/Web/StellaOps.Web/` + +## Dependencies & Concurrency + +- **Depends on**: Sprint 20260104_006 (Config API), Sprint 20260104_007 (Alerts) +- **Parallel with**: None (final UI sprint) +- **Blocks**: Feature release + +## Documentation Prerequisites + +- docs/modules/web/architecture.md +- Angular v17 component patterns + +## Delivery Tracker + +| # | Task ID | Status | Key dependency / next step | Owners | Task Definition | +| --- | --- | --- | --- | --- | --- | +| 1 | SDU-001 | TODO | None | Frontend Guild | Create secret-detection feature module | +| 2 | SDU-002 | TODO | SDU-001 | Frontend Guild | Build settings page component | +| 3 | SDU-003 | TODO | SDU-002 | Frontend Guild | Add revelation policy selector | +| 4 | SDU-004 | TODO | SDU-002 | Frontend Guild | Build rule category toggles | +| 5 | SDU-005 | TODO | SDU-001 | Frontend Guild | Create findings list component | +| 6 | SDU-006 | TODO | SDU-005 | Frontend Guild | Implement masked value display | +| 7 | SDU-007 | TODO | SDU-005 | Frontend Guild | Add finding detail drawer | +| 8 | SDU-008 | TODO | SDU-001 | Frontend Guild | Build exception manager component | +| 9 | SDU-009 | TODO | SDU-008 | Frontend Guild | Create exception form with validation | +| 10 | SDU-010 | TODO | SDU-001 | Frontend Guild | Build alert destination config | +| 11 | SDU-011 | TODO | SDU-010 | Frontend Guild | Add channel test functionality | +| 12 | SDU-012 | TODO | All | Frontend Guild | Add E2E tests | + +## Task Details + +### SDU-002: Settings Page Component + +```typescript +// secret-detection-settings.component.ts +@Component({ + selector: 'app-secret-detection-settings', + template: ` +
+
+

Secret Detection

+ + {{ settings()?.enabled ? 'Enabled' : 'Disabled' }} + +
+ + + + + + + + + + + + + + + + +
+ ` +}) +export class SecretDetectionSettingsComponent { + private settingsService = inject(SecretDetectionSettingsService); + + settings = this.settingsService.settings; + availableCategories = this.settingsService.availableCategories; + + // ... handlers +} +``` + +### SDU-003: Revelation Policy Selector + +```typescript +// revelation-policy-config.component.ts +@Component({ + selector: 'app-revelation-policy-config', + template: ` + + + Secret Revelation Policy + + Control how detected secrets are displayed + + + + + + + +
+ Full Mask + [REDACTED] +

No secret value shown. Safest option.

+
+
+ + +
+ Partial Reveal + AKIA****WXYZ +

Show first/last 4 characters. Helps identify specific secrets.

+
+
+ + +
+ Full Reveal + AKIAIOSFODNN7EXAMPLE +

Show complete value. Requires security-admin role.

+
+
+
+ + + +

Context-Specific Policies

+
+ + Export Reports + + Full Mask + Partial Reveal + + + + + Logs & Telemetry + + Full Mask (Enforced) + + Secrets are never logged in full + +
+
+
+ ` +}) +``` + +### SDU-005: Findings List Component + +```typescript +// secret-findings-list.component.ts +@Component({ + selector: 'app-secret-findings-list', + template: ` +
+
+

Secret Findings

+
+ + Severity + + Critical + High + Medium + Low + + + + + Status + + Open + Dismissed + Excepted + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Severity + + Rule +
+ {{ finding.ruleName }} + {{ finding.ruleCategory }} +
+
Location + {{ finding.filePath }}:{{ finding.lineNumber }} + Detected Value + + + + + + + + +
+
+ ` +}) +``` + +### SDU-006: Masked Value Display + +```typescript +// masked-secret-value.component.ts +@Component({ + selector: 'app-masked-secret-value', + template: ` +
+ {{ displayValue() }} + + @if (canReveal() && !isRevealed()) { + + } + + @if (isRevealed()) { + + + } +
+ `, + styles: [` + .masked-value { + font-family: monospace; + display: flex; + align-items: center; + gap: 8px; + } + .revealed code { + background: #fff3cd; + padding: 4px 8px; + border-radius: 4px; + } + `] +}) +export class MaskedSecretValueComponent { + value = input.required(); + policy = input.required(); + canReveal = input(false); + + private revealed = signal(false); + isRevealed = computed(() => this.revealed() && this.canReveal()); + + displayValue = computed(() => { + if (this.isRevealed()) { + return this.value(); + } + return this.maskValue(this.value(), this.policy()); + }); + + reveal() { + // Log reveal action for audit + this.auditService.logSecretReveal(this.value()); + this.revealed.set(true); + } + + hide() { + this.revealed.set(false); + } + + private maskValue(value: string, policy: SecretRevelationPolicy): string { + switch (policy) { + case 'FullMask': + return '[REDACTED]'; + case 'PartialReveal': + if (value.length <= 8) return '*'.repeat(value.length); + return `${value.slice(0, 4)}${'*'.repeat(Math.min(8, value.length - 8))}${value.slice(-4)}`; + default: + return '[REDACTED]'; + } + } +} +``` + +### SDU-010: Alert Destination Configuration + +```typescript +// alert-destination-config.component.ts +@Component({ + selector: 'app-alert-destination-config', + template: ` + + + Alert Destinations + + + +
+ + Enable Alerts + + + + Minimum Severity + + Critical only + High and above + Medium and above + All findings + + +
+ + + +

Configured Channels

+
+ @for (dest of settings().destinations; track dest.id) { + +
+ {{ getChannelIcon(dest.channelType) }} + {{ dest.channelType }} + {{ dest.channelId }} +
+
+ + +
+
+ } +
+ + +
+
+ ` +}) +``` + +## UI Mockups + +### Settings Page Layout +``` +┌─────────────────────────────────────────────────────────────┐ +│ Secret Detection [Enabled ●] │ +├─────────────────────────────────────────────────────────────┤ +│ [General] [Exceptions] [Alerts] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─ Revelation Policy ─────────────────────────────────┐ │ +│ │ ○ Full Mask [REDACTED] │ │ +│ │ ● Partial Reveal AKIA****WXYZ │ │ +│ │ ○ Full Reveal (requires security-admin) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Rule Categories ───────────────────────────────────┐ │ +│ │ ☑ AWS Credentials ☑ GCP Service Accounts │ │ +│ │ ☑ Generic API Keys ☑ Private Keys │ │ +│ │ ☐ Internal Tokens ☑ Database Credentials │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Findings List +``` +┌─────────────────────────────────────────────────────────────┐ +│ Secret Findings │ +│ Severity: [All ▼] Status: [Open ▼] Image: [All ▼] │ +├─────────────────────────────────────────────────────────────┤ +│ SEV │ RULE │ LOCATION │ VALUE │ +├─────────────────────────────────────────────────────────────┤ +│ 🔴 │ AWS Access Key │ config.yaml:42 │ AKIA****XYZ │ +│ 🟠 │ Generic API Key │ .env:15 │ sk_l****abc │ +│ 🟡 │ Private Key │ certs/server.key │ [REDACTED] │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +src/Web/StellaOps.Web/src/app/ +├── features/ +│ └── secret-detection/ +│ ├── secret-detection.module.ts +│ ├── secret-detection.routes.ts +│ ├── pages/ +│ │ ├── settings/ +│ │ │ └── secret-detection-settings.component.ts +│ │ └── findings/ +│ │ └── secret-findings-list.component.ts +│ ├── components/ +│ │ ├── revelation-policy-config/ +│ │ ├── rule-category-selector/ +│ │ ├── exception-manager/ +│ │ ├── alert-destination-config/ +│ │ ├── masked-secret-value/ +│ │ └── finding-detail-drawer/ +│ └── services/ +│ ├── secret-detection-settings.service.ts +│ └── secret-findings.service.ts +``` + +## Decisions & Risks + +| Decision | Rationale | +|----------|-----------| +| Angular Material | Consistent with existing UI | +| Signal-based state | Modern Angular patterns | +| Audit logging on reveal | Compliance requirement | +| Lazy-loaded module | Performance optimization | + +## Execution Log + +| Date | Action | Notes | +|------|--------|-------| +| 2026-01-04 | Sprint created | UI components for secret detection | + diff --git a/src/AirGap/StellaOps.AirGap.Importer/Policy/OfflineVerificationPolicy.cs b/src/AirGap/StellaOps.AirGap.Importer/Policy/OfflineVerificationPolicy.cs index 548700aa6..c3345d812 100644 --- a/src/AirGap/StellaOps.AirGap.Importer/Policy/OfflineVerificationPolicy.cs +++ b/src/AirGap/StellaOps.AirGap.Importer/Policy/OfflineVerificationPolicy.cs @@ -43,6 +43,7 @@ public sealed record OfflineVerificationPolicy return values .Select(static value => value?.Trim()) .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -203,6 +204,7 @@ public sealed record OfflineCertConstraints return values .Select(static value => value?.Trim()) .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase) .ToArray(); diff --git a/src/AirGap/StellaOps.AirGap.Importer/Reconciliation/JsonNormalizer.cs b/src/AirGap/StellaOps.AirGap.Importer/Reconciliation/JsonNormalizer.cs index 7d58e8555..42438e62a 100644 --- a/src/AirGap/StellaOps.AirGap.Importer/Reconciliation/JsonNormalizer.cs +++ b/src/AirGap/StellaOps.AirGap.Importer/Reconciliation/JsonNormalizer.cs @@ -41,7 +41,7 @@ public static class JsonNormalizer } var normalized = NormalizeNode(node, options); - return normalized.ToJsonString(SerializerOptions); + return normalized?.ToJsonString(SerializerOptions) ?? "null"; } /// diff --git a/src/AirGap/StellaOps.AirGap.Importer/Reconciliation/Parsers/SbomNormalizer.cs b/src/AirGap/StellaOps.AirGap.Importer/Reconciliation/Parsers/SbomNormalizer.cs index d8fd92be1..33ac193d8 100644 --- a/src/AirGap/StellaOps.AirGap.Importer/Reconciliation/Parsers/SbomNormalizer.cs +++ b/src/AirGap/StellaOps.AirGap.Importer/Reconciliation/Parsers/SbomNormalizer.cs @@ -128,7 +128,8 @@ public sealed class SbomNormalizer /// private JsonNode NormalizeGeneric(JsonNode node) { - return NormalizeNode(node); + // NormalizeNode only returns null if input is null; node is non-null here + return NormalizeNode(node)!; } /// diff --git a/src/AirGap/StellaOps.AirGap.Importer/Validation/RuleBundleValidator.cs b/src/AirGap/StellaOps.AirGap.Importer/Validation/RuleBundleValidator.cs new file mode 100644 index 000000000..d82fd8e51 --- /dev/null +++ b/src/AirGap/StellaOps.AirGap.Importer/Validation/RuleBundleValidator.cs @@ -0,0 +1,344 @@ +// ----------------------------------------------------------------------------- +// RuleBundleValidator.cs +// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) +// Task: OKS-003 - Create bundle verification in Importer +// Description: Validates rule bundles (secrets, malware, etc.) for offline import. +// ----------------------------------------------------------------------------- + +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StellaOps.AirGap.Importer.Contracts; +using StellaOps.AirGap.Importer.Telemetry; +using StellaOps.AirGap.Importer.Versioning; + +namespace StellaOps.AirGap.Importer.Validation; + +/// +/// Validates rule bundles (secrets, malware, etc.) for offline import. +/// Verifies signature, version monotonicity, and file digests. +/// +public sealed class RuleBundleValidator +{ + private readonly DsseVerifier _dsseVerifier; + private readonly IVersionMonotonicityChecker _monotonicityChecker; + private readonly ILogger _logger; + + public RuleBundleValidator( + DsseVerifier dsseVerifier, + IVersionMonotonicityChecker monotonicityChecker, + ILogger logger) + { + _dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier)); + _monotonicityChecker = monotonicityChecker ?? throw new ArgumentNullException(nameof(monotonicityChecker)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Validates a rule bundle for import. + /// + public async Task ValidateAsync( + RuleBundleValidationRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleType); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Version); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleDirectory); + + using var tenantScope = _logger.BeginTenantScope(request.TenantId); + var verificationLog = new List(capacity: 8); + + // Verify manifest file exists + var manifestPath = Path.Combine(request.BundleDirectory, $"{request.BundleId}.manifest.json"); + if (!File.Exists(manifestPath)) + { + var reason = $"manifest-not-found:{manifestPath}"; + verificationLog.Add(reason); + _logger.LogWarning( + "offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}", + request.TenantId, + request.BundleId, + reason); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + + // Read and parse manifest + string manifestJson; + RuleBundleManifest? manifest; + try + { + manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken); + manifest = JsonSerializer.Deserialize(manifestJson, JsonOptions); + if (manifest is null) + { + var reason = "manifest-parse-failed:null"; + verificationLog.Add(reason); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + } + catch (Exception ex) + { + var reason = $"manifest-parse-failed:{ex.GetType().Name.ToLowerInvariant()}"; + verificationLog.Add(reason); + _logger.LogWarning( + ex, + "offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}", + request.TenantId, + request.BundleId, + reason); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + + // Verify signature if envelope provided + if (request.SignatureEnvelope is not null) + { + var signatureResult = _dsseVerifier.Verify(request.SignatureEnvelope, request.TrustRoots, _logger); + if (!signatureResult.IsValid) + { + var reason = $"signature-invalid:{signatureResult.Reason}"; + verificationLog.Add(reason); + _logger.LogWarning( + "offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}", + request.TenantId, + request.BundleId, + reason); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + verificationLog.Add($"signature:verified"); + } + else if (request.RequireSignature) + { + var reason = "signature-required-but-missing"; + verificationLog.Add(reason); + _logger.LogWarning( + "offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}", + request.TenantId, + request.BundleId, + reason); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + + // Verify file digests + var digestErrors = new List(); + foreach (var file in manifest.Files) + { + var filePath = Path.Combine(request.BundleDirectory, file.Name); + if (!File.Exists(filePath)) + { + digestErrors.Add($"file-missing:{file.Name}"); + continue; + } + + var actualDigest = await ComputeFileDigestAsync(filePath, cancellationToken); + if (!string.Equals(actualDigest, file.Digest, StringComparison.OrdinalIgnoreCase)) + { + digestErrors.Add($"digest-mismatch:{file.Name}:expected={file.Digest}:actual={actualDigest}"); + } + } + + if (digestErrors.Count > 0) + { + var reason = string.Join(";", digestErrors); + verificationLog.Add(reason); + _logger.LogWarning( + "offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}", + request.TenantId, + request.BundleId, + reason); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + verificationLog.Add($"digests:verified:{manifest.Files.Count}"); + + // Verify version monotonicity (CalVer format YYYY.MM) + var bundleVersionKey = $"rulebundle:{request.BundleType}:{request.BundleId}"; + BundleVersion incomingVersion; + try + { + incomingVersion = BundleVersion.Parse(request.Version, request.CreatedAt ?? DateTimeOffset.UtcNow); + } + catch (Exception ex) + { + var reason = $"version-parse-failed:{ex.GetType().Name.ToLowerInvariant()}"; + verificationLog.Add(reason); + _logger.LogWarning( + ex, + "offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}", + request.TenantId, + request.BundleId, + reason); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + + var monotonicity = await _monotonicityChecker.CheckAsync( + request.TenantId, + bundleVersionKey, + incomingVersion, + cancellationToken); + + if (!monotonicity.IsMonotonic && !request.ForceActivate) + { + var reason = $"version-non-monotonic:incoming={incomingVersion.SemVer}:current={monotonicity.CurrentVersion?.SemVer ?? "(none)"}"; + verificationLog.Add(reason); + _logger.LogWarning( + "offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}", + request.TenantId, + request.BundleId, + reason); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + + if (!monotonicity.IsMonotonic && request.ForceActivate) + { + _logger.LogWarning( + "offlinekit.rulebundle.force_activation tenant_id={tenant_id} bundle_id={bundle_id} incoming_version={incoming_version} current_version={current_version} reason={reason}", + request.TenantId, + request.BundleId, + incomingVersion.SemVer, + monotonicity.CurrentVersion?.SemVer, + request.ForceActivateReason); + } + + verificationLog.Add($"version:monotonic:{incomingVersion.SemVer}"); + + // Record activation + try + { + var combinedDigest = ComputeCombinedDigest(manifest.Files); + await _monotonicityChecker.RecordActivationAsync( + request.TenantId, + bundleVersionKey, + incomingVersion, + combinedDigest, + request.ForceActivate, + request.ForceActivateReason, + cancellationToken); + } + catch (Exception ex) + { + var reason = $"version-store-write-failed:{ex.GetType().Name.ToLowerInvariant()}"; + verificationLog.Add(reason); + _logger.LogError( + ex, + "offlinekit.rulebundle.activation failed tenant_id={tenant_id} bundle_id={bundle_id}", + request.TenantId, + request.BundleId); + return RuleBundleValidationResult.Failure(reason, verificationLog); + } + + _logger.LogInformation( + "offlinekit.rulebundle.validation succeeded tenant_id={tenant_id} bundle_id={bundle_id} bundle_type={bundle_type} version={version} rule_count={rule_count}", + request.TenantId, + request.BundleId, + request.BundleType, + request.Version, + manifest.RuleCount); + + return RuleBundleValidationResult.Success( + "rulebundle-validated", + verificationLog, + manifest.RuleCount, + manifest.SignerKeyId); + } + + private static async Task ComputeFileDigestAsync(string filePath, CancellationToken ct) + { + await using var stream = File.OpenRead(filePath); + var hash = await SHA256.HashDataAsync(stream, ct); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static string ComputeCombinedDigest(IReadOnlyList files) + { + var sortedDigests = files + .OrderBy(f => f.Name, StringComparer.Ordinal) + .Select(f => f.Digest) + .ToArray(); + + var combined = string.Join(":", sortedDigests); + var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(combined)); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; +} + +/// +/// Request for validating a rule bundle. +/// +public sealed record RuleBundleValidationRequest( + string TenantId, + string BundleId, + string BundleType, + string Version, + string BundleDirectory, + DateTimeOffset? CreatedAt, + DsseEnvelope? SignatureEnvelope, + TrustRootConfig TrustRoots, + bool RequireSignature, + bool ForceActivate, + string? ForceActivateReason); + +/// +/// Result of rule bundle validation. +/// +public sealed record RuleBundleValidationResult +{ + public bool IsValid { get; init; } + public string Reason { get; init; } = string.Empty; + public IReadOnlyList VerificationLog { get; init; } = []; + public int RuleCount { get; init; } + public string? SignerKeyId { get; init; } + + public static RuleBundleValidationResult Success( + string reason, + IReadOnlyList verificationLog, + int ruleCount, + string? signerKeyId) => new() + { + IsValid = true, + Reason = reason, + VerificationLog = verificationLog, + RuleCount = ruleCount, + SignerKeyId = signerKeyId + }; + + public static RuleBundleValidationResult Failure( + string reason, + IReadOnlyList verificationLog) => new() + { + IsValid = false, + Reason = reason, + VerificationLog = verificationLog + }; +} + +/// +/// Manifest for a rule bundle. +/// +internal sealed class RuleBundleManifest +{ + public string BundleId { get; set; } = string.Empty; + public string BundleType { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public int RuleCount { get; set; } + public string? SignerKeyId { get; set; } + public DateTimeOffset? SignedAt { get; set; } + public List Files { get; set; } = []; +} + +/// +/// File entry in a rule bundle manifest. +/// +internal sealed class RuleBundleFileEntry +{ + public string Name { get; set; } = string.Empty; + public string Digest { get; set; } = string.Empty; + public long SizeBytes { get; set; } +} diff --git a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Models/BundleManifest.cs b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Models/BundleManifest.cs index a0ea7f40d..e9fafbfa5 100644 --- a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Models/BundleManifest.cs +++ b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Models/BundleManifest.cs @@ -20,6 +20,7 @@ public sealed record BundleManifest public ImmutableArray Catalogs { get; init; } = []; public RekorSnapshot? RekorSnapshot { get; init; } public ImmutableArray CryptoProviders { get; init; } = []; + public ImmutableArray RuleBundles { get; init; } = []; public long TotalSizeBytes { get; init; } public string? BundleDigest { get; init; } } @@ -102,3 +103,39 @@ public sealed record CryptoProviderComponent( string Digest, long SizeBytes, ImmutableArray SupportedAlgorithms); + +/// +/// Component for a rule bundle (e.g., secrets detection rules). +/// +/// Bundle identifier (e.g., "secrets.ruleset"). +/// Bundle type (e.g., "secrets", "malware"). +/// Bundle version in YYYY.MM format. +/// Relative path to the bundle directory. +/// Combined digest of all files in the bundle. +/// Total size of the bundle in bytes. +/// Number of rules in the bundle. +/// Key ID used to sign the bundle. +/// When the bundle was signed. +/// List of files in the bundle. +public sealed record RuleBundleComponent( + string BundleId, + string BundleType, + string Version, + string RelativePath, + string Digest, + long SizeBytes, + int RuleCount, + string? SignerKeyId, + DateTimeOffset? SignedAt, + ImmutableArray Files); + +/// +/// A file within a rule bundle component. +/// +/// Filename (e.g., "secrets.ruleset.manifest.json"). +/// SHA256 digest of the file. +/// File size in bytes. +public sealed record RuleBundleFileComponent( + string Name, + string Digest, + long SizeBytes); diff --git a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Models/KnowledgeSnapshotManifest.cs b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Models/KnowledgeSnapshotManifest.cs index 54b287b7b..f06f01949 100644 --- a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Models/KnowledgeSnapshotManifest.cs +++ b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Models/KnowledgeSnapshotManifest.cs @@ -25,6 +25,7 @@ public sealed class KnowledgeSnapshotManifest public List VexStatements { get; init; } = []; public List Policies { get; init; } = []; public List TrustRoots { get; init; } = []; + public List RuleBundles { get; init; } = []; public TimeAnchorEntry? TimeAnchor { get; set; } } @@ -81,6 +82,79 @@ public sealed class TrustRootSnapshotEntry public DateTimeOffset? ExpiresAt { get; init; } } +/// +/// Entry for a rule bundle in the snapshot. +/// Used for detection rule bundles (secrets, malware, etc.). +/// +public sealed class RuleBundleSnapshotEntry +{ + /// + /// Bundle identifier (e.g., "secrets.ruleset"). + /// + public required string BundleId { get; init; } + + /// + /// Bundle type (e.g., "secrets", "malware"). + /// + public required string BundleType { get; init; } + + /// + /// Bundle version in YYYY.MM format. + /// + public required string Version { get; init; } + + /// + /// Relative path to the bundle directory in the snapshot. + /// + public required string RelativePath { get; init; } + + /// + /// List of files in the bundle with their digests. + /// + public required List Files { get; init; } + + /// + /// Number of rules in the bundle. + /// + public int RuleCount { get; init; } + + /// + /// Key ID used to sign the bundle. + /// + public string? SignerKeyId { get; init; } + + /// + /// When the bundle was signed. + /// + public DateTimeOffset? SignedAt { get; init; } + + /// + /// When the bundle signature was verified during export. + /// + public DateTimeOffset? VerifiedAt { get; init; } +} + +/// +/// A file within a rule bundle. +/// +public sealed class RuleBundleFile +{ + /// + /// Filename (e.g., "secrets.ruleset.manifest.json"). + /// + public required string Name { get; init; } + + /// + /// SHA256 digest of the file. + /// + public required string Digest { get; init; } + + /// + /// File size in bytes. + /// + public required long SizeBytes { get; init; } +} + /// /// Time anchor entry in the manifest. /// diff --git a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/BundleBuilder.cs b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/BundleBuilder.cs index 3f2320b52..a361df54d 100644 --- a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/BundleBuilder.cs +++ b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/BundleBuilder.cs @@ -81,9 +81,64 @@ public sealed class BundleBuilder : IBundleBuilder cryptoConfig.ExpiresAt)); } + var ruleBundles = new List(); + foreach (var ruleBundleConfig in request.RuleBundles) + { + // Validate relative path before combining + var targetDir = PathValidation.SafeCombine(outputPath, ruleBundleConfig.RelativePath); + Directory.CreateDirectory(targetDir); + + var files = new List(); + long bundleTotalSize = 0; + var digestBuilder = new System.Text.StringBuilder(); + + // Copy all files from source directory + if (Directory.Exists(ruleBundleConfig.SourceDirectory)) + { + foreach (var sourceFile in Directory.GetFiles(ruleBundleConfig.SourceDirectory) + .OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)) + { + var fileName = Path.GetFileName(sourceFile); + var targetFile = Path.Combine(targetDir, fileName); + + await using (var input = File.OpenRead(sourceFile)) + await using (var output = File.Create(targetFile)) + { + await input.CopyToAsync(output, ct).ConfigureAwait(false); + } + + await using var digestStream = File.OpenRead(targetFile); + var hash = await SHA256.HashDataAsync(digestStream, ct).ConfigureAwait(false); + var fileDigest = Convert.ToHexString(hash).ToLowerInvariant(); + + var fileInfo = new FileInfo(targetFile); + files.Add(new RuleBundleFileComponent(fileName, fileDigest, fileInfo.Length)); + bundleTotalSize += fileInfo.Length; + digestBuilder.Append(fileDigest); + } + } + + // Compute combined digest from all file digests + var combinedDigest = Convert.ToHexString( + SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(digestBuilder.ToString()))).ToLowerInvariant(); + + ruleBundles.Add(new RuleBundleComponent( + ruleBundleConfig.BundleId, + ruleBundleConfig.BundleType, + ruleBundleConfig.Version, + ruleBundleConfig.RelativePath, + combinedDigest, + bundleTotalSize, + ruleBundleConfig.RuleCount, + ruleBundleConfig.SignerKeyId, + ruleBundleConfig.SignedAt, + files.ToImmutableArray())); + } + var totalSize = feeds.Sum(f => f.SizeBytes) + policies.Sum(p => p.SizeBytes) + - cryptoMaterials.Sum(c => c.SizeBytes); + cryptoMaterials.Sum(c => c.SizeBytes) + + ruleBundles.Sum(r => r.SizeBytes); var manifest = new BundleManifest { @@ -96,6 +151,7 @@ public sealed class BundleBuilder : IBundleBuilder Feeds = feeds.ToImmutableArray(), Policies = policies.ToImmutableArray(), CryptoMaterials = cryptoMaterials.ToImmutableArray(), + RuleBundles = ruleBundles.ToImmutableArray(), TotalSizeBytes = totalSize }; @@ -138,7 +194,8 @@ public sealed record BundleBuildRequest( DateTimeOffset? ExpiresAt, IReadOnlyList Feeds, IReadOnlyList Policies, - IReadOnlyList CryptoMaterials); + IReadOnlyList CryptoMaterials, + IReadOnlyList RuleBundles); public abstract record BundleComponentSource(string SourcePath, string RelativePath); @@ -169,3 +226,24 @@ public sealed record CryptoBuildConfig( CryptoComponentType Type, DateTimeOffset? ExpiresAt) : BundleComponentSource(SourcePath, RelativePath); + +/// +/// Configuration for building a rule bundle component. +/// +/// Bundle identifier (e.g., "secrets.ruleset"). +/// Bundle type (e.g., "secrets", "malware"). +/// Bundle version in YYYY.MM format. +/// Source directory containing the rule bundle files. +/// Relative path in the output bundle. +/// Number of rules in the bundle. +/// Key ID used to sign the bundle. +/// When the bundle was signed. +public sealed record RuleBundleBuildConfig( + string BundleId, + string BundleType, + string Version, + string SourceDirectory, + string RelativePath, + int RuleCount, + string? SignerKeyId, + DateTimeOffset? SignedAt); diff --git a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/SnapshotBundleReader.cs b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/SnapshotBundleReader.cs index 77d6950be..9a7c9044a 100644 --- a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/SnapshotBundleReader.cs +++ b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/SnapshotBundleReader.cs @@ -408,6 +408,38 @@ public sealed class SnapshotBundleReader : ISnapshotBundleReader entries.Add(new BundleEntry(trust.RelativePath, digest, content.Length)); } + foreach (var ruleBundle in manifest.RuleBundles) + { + // Verify each file in the rule bundle + foreach (var file in ruleBundle.Files) + { + var relativePath = $"{ruleBundle.RelativePath}/{file.Name}"; + var filePath = Path.Combine(bundleDir, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(filePath)) + { + return new MerkleVerificationResult + { + Verified = false, + Error = $"Missing rule bundle file: {relativePath}" + }; + } + + var content = await File.ReadAllBytesAsync(filePath, cancellationToken); + var digest = ComputeSha256(content); + + if (digest != file.Digest) + { + return new MerkleVerificationResult + { + Verified = false, + Error = $"Digest mismatch for rule bundle file {relativePath}" + }; + } + + entries.Add(new BundleEntry(relativePath, digest, content.Length)); + } + } + // Compute merkle root var computedRoot = ComputeMerkleRoot(entries); diff --git a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/SnapshotBundleWriter.cs b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/SnapshotBundleWriter.cs index 4c4ca374c..db3b74365 100644 --- a/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/SnapshotBundleWriter.cs +++ b/src/AirGap/__Libraries/StellaOps.AirGap.Bundle/Services/SnapshotBundleWriter.cs @@ -186,6 +186,52 @@ public sealed class SnapshotBundleWriter : ISnapshotBundleWriter } } + // Write rule bundles + if (request.RuleBundles is { Count: > 0 }) + { + var rulesDir = Path.Combine(tempDir, "rules"); + Directory.CreateDirectory(rulesDir); + + foreach (var ruleBundle in request.RuleBundles) + { + var bundleDir = Path.Combine(rulesDir, ruleBundle.BundleId); + Directory.CreateDirectory(bundleDir); + + var bundleFiles = new List(); + var bundleRelativePath = $"rules/{ruleBundle.BundleId}"; + + foreach (var file in ruleBundle.Files) + { + var filePath = Path.Combine(bundleDir, file.Name); + await File.WriteAllBytesAsync(filePath, file.Content, cancellationToken); + + var relativePath = $"{bundleRelativePath}/{file.Name}"; + var digest = ComputeSha256(file.Content); + + entries.Add(new BundleEntry(relativePath, digest, file.Content.Length)); + bundleFiles.Add(new RuleBundleFile + { + Name = file.Name, + Digest = digest, + SizeBytes = file.Content.Length + }); + } + + manifest.RuleBundles.Add(new RuleBundleSnapshotEntry + { + BundleId = ruleBundle.BundleId, + BundleType = ruleBundle.BundleType, + Version = ruleBundle.Version, + RelativePath = bundleRelativePath, + Files = bundleFiles, + RuleCount = ruleBundle.RuleCount, + SignerKeyId = ruleBundle.SignerKeyId, + SignedAt = ruleBundle.SignedAt, + VerifiedAt = ruleBundle.VerifiedAt + }); + } + } + // Write time anchor if (request.TimeAnchor is not null) { @@ -389,6 +435,7 @@ public sealed record SnapshotBundleRequest public List VexStatements { get; init; } = []; public List Policies { get; init; } = []; public List TrustRoots { get; init; } = []; + public List RuleBundles { get; init; } = []; public TimeAnchorContent? TimeAnchor { get; init; } /// @@ -445,6 +492,68 @@ public sealed record TrustRootContent public DateTimeOffset? ExpiresAt { get; init; } } +/// +/// Content for a rule bundle (e.g., secrets detection rules). +/// +public sealed record RuleBundleContent +{ + /// + /// Bundle identifier (e.g., "secrets.ruleset"). + /// + public required string BundleId { get; init; } + + /// + /// Bundle type (e.g., "secrets", "malware"). + /// + public required string BundleType { get; init; } + + /// + /// Bundle version in YYYY.MM format. + /// + public required string Version { get; init; } + + /// + /// Files in the bundle. + /// + public required List Files { get; init; } + + /// + /// Number of rules in the bundle. + /// + public int RuleCount { get; init; } + + /// + /// Key ID used to sign the bundle. + /// + public string? SignerKeyId { get; init; } + + /// + /// When the bundle was signed. + /// + public DateTimeOffset? SignedAt { get; init; } + + /// + /// When the bundle signature was verified during export. + /// + public DateTimeOffset? VerifiedAt { get; init; } +} + +/// +/// A file within a rule bundle. +/// +public sealed record RuleBundleFileContent +{ + /// + /// Filename (e.g., "secrets.ruleset.manifest.json"). + /// + public required string Name { get; init; } + + /// + /// File content. + /// + public required byte[] Content { get; init; } +} + public sealed record TimeAnchorContent { public required DateTimeOffset AnchorTime { get; init; } diff --git a/src/AirGap/__Tests/StellaOps.AirGap.Importer.Tests/Validation/RuleBundleValidatorTests.cs b/src/AirGap/__Tests/StellaOps.AirGap.Importer.Tests/Validation/RuleBundleValidatorTests.cs new file mode 100644 index 000000000..d648d527e --- /dev/null +++ b/src/AirGap/__Tests/StellaOps.AirGap.Importer.Tests/Validation/RuleBundleValidatorTests.cs @@ -0,0 +1,412 @@ +// ----------------------------------------------------------------------------- +// RuleBundleValidatorTests.cs +// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) +// Task: OKS-008 - Add integration tests for offline flow +// Description: Tests for rule bundle validation in offline import +// ----------------------------------------------------------------------------- + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using StellaOps.AirGap.Importer.Contracts; +using StellaOps.AirGap.Importer.Validation; +using StellaOps.AirGap.Importer.Versioning; +using StellaOps.TestKit; + +namespace StellaOps.AirGap.Importer.Tests.Validation; + +[Trait("Category", TestCategories.Unit)] +public sealed class RuleBundleValidatorTests : IDisposable +{ + private readonly string _tempDir; + private readonly CapturingMonotonicityChecker _monotonicityChecker; + + public RuleBundleValidatorTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "stellaops-rulebundle-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + _monotonicityChecker = new CapturingMonotonicityChecker(); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch + { + // Best-effort cleanup + } + } + + [Fact] + public async Task ValidateAsync_WhenManifestNotFound_ShouldFail() + { + // Arrange + var validator = CreateValidator(); + var bundleDir = Path.Combine(_tempDir, "missing-manifest"); + Directory.CreateDirectory(bundleDir); + + var request = CreateRequest(bundleDir, "test-bundle", "secrets"); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Reason.Should().StartWith("manifest-not-found"); + } + + [Fact] + public async Task ValidateAsync_WhenManifestParseError_ShouldFail() + { + // Arrange + var validator = CreateValidator(); + var bundleDir = Path.Combine(_tempDir, "invalid-manifest"); + Directory.CreateDirectory(bundleDir); + + await File.WriteAllTextAsync( + Path.Combine(bundleDir, "test-bundle.manifest.json"), + "not-valid-json{{{"); + + var request = CreateRequest(bundleDir, "test-bundle", "secrets"); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Reason.Should().StartWith("manifest-parse-failed"); + } + + [Fact] + public async Task ValidateAsync_WhenFileDigestMismatch_ShouldFail() + { + // Arrange + var validator = CreateValidator(); + var bundleDir = Path.Combine(_tempDir, "digest-mismatch"); + Directory.CreateDirectory(bundleDir); + + var rulesContent = "{\"id\":\"test-rule\"}"; + var rulesPath = Path.Combine(bundleDir, "test-bundle.rules.jsonl"); + await File.WriteAllTextAsync(rulesPath, rulesContent); + + // Create manifest with wrong digest + var manifest = new + { + bundleId = "test-bundle", + bundleType = "secrets", + version = "2026.1.0", + ruleCount = 1, + files = new[] + { + new + { + name = "test-bundle.rules.jsonl", + digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000", + sizeBytes = rulesContent.Length + } + } + }; + + await File.WriteAllTextAsync( + Path.Combine(bundleDir, "test-bundle.manifest.json"), + JsonSerializer.Serialize(manifest)); + + var request = CreateRequest(bundleDir, "test-bundle", "secrets"); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Reason.Should().Contain("digest-mismatch"); + } + + [Fact] + public async Task ValidateAsync_WhenFileMissing_ShouldFail() + { + // Arrange + var validator = CreateValidator(); + var bundleDir = Path.Combine(_tempDir, "file-missing"); + Directory.CreateDirectory(bundleDir); + + var manifest = new + { + bundleId = "test-bundle", + bundleType = "secrets", + version = "2026.1.0", + ruleCount = 1, + files = new[] + { + new + { + name = "test-bundle.rules.jsonl", + digest = "sha256:abcd1234", + sizeBytes = 100 + } + } + }; + + await File.WriteAllTextAsync( + Path.Combine(bundleDir, "test-bundle.manifest.json"), + JsonSerializer.Serialize(manifest)); + + var request = CreateRequest(bundleDir, "test-bundle", "secrets"); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Reason.Should().Contain("file-missing"); + } + + [Fact] + public async Task ValidateAsync_WhenSignatureRequiredButMissing_ShouldFail() + { + // Arrange + var validator = CreateValidator(); + var bundleDir = await CreateValidBundleAsync("sig-required"); + + var request = CreateRequest( + bundleDir, + "test-bundle", + "secrets", + signatureEnvelope: null, + requireSignature: true); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Reason.Should().Be("signature-required-but-missing"); + } + + [Fact] + public async Task ValidateAsync_WhenVersionNonMonotonic_ShouldFail() + { + // Arrange + var monotonicityChecker = new NonMonotonicChecker(); + var validator = CreateValidator(monotonicityChecker); + var bundleDir = await CreateValidBundleAsync("non-monotonic"); + + var request = CreateRequest( + bundleDir, + "test-bundle", + "secrets", + requireSignature: false); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeFalse(); + result.Reason.Should().StartWith("version-non-monotonic"); + } + + [Fact] + public async Task ValidateAsync_WhenAllChecksPass_ShouldSucceed() + { + // Arrange + var validator = CreateValidator(); + var bundleDir = await CreateValidBundleAsync("all-pass"); + + var request = CreateRequest( + bundleDir, + "test-bundle", + "secrets", + requireSignature: false); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeTrue(); + result.Reason.Should().Be("rulebundle-validated"); + result.RuleCount.Should().BeGreaterThan(0); + } + + [Fact] + public async Task ValidateAsync_WhenForceActivateWithOlderVersion_ShouldSucceed() + { + // Arrange + var monotonicityChecker = new NonMonotonicChecker(); + var validator = CreateValidator(monotonicityChecker); + var bundleDir = await CreateValidBundleAsync("force-activate"); + + var request = CreateRequest( + bundleDir, + "test-bundle", + "secrets", + requireSignature: false, + forceActivate: true, + forceActivateReason: "Rollback due to compatibility issue"); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeTrue(); + result.Reason.Should().Be("rulebundle-validated"); + } + + [Fact] + public async Task ValidateAsync_ShouldRecordActivation() + { + // Arrange + var validator = CreateValidator(); + var bundleDir = await CreateValidBundleAsync("record-activation"); + + var request = CreateRequest( + bundleDir, + "test-bundle", + "secrets", + requireSignature: false); + + // Act + var result = await validator.ValidateAsync(request); + + // Assert + result.IsValid.Should().BeTrue(); + _monotonicityChecker.RecordedActivations.Should().HaveCount(1); + _monotonicityChecker.RecordedActivations[0].BundleType.Should().Contain("secrets"); + } + + private RuleBundleValidator CreateValidator(IVersionMonotonicityChecker? checker = null) + { + return new RuleBundleValidator( + new DsseVerifier(), + checker ?? _monotonicityChecker, + NullLogger.Instance); + } + + private async Task CreateValidBundleAsync(string name) + { + var bundleDir = Path.Combine(_tempDir, name); + Directory.CreateDirectory(bundleDir); + + // Create rules file + var rulesContent = "{\"id\":\"test-rule-1\",\"name\":\"Test Rule\",\"pattern\":\"SECRET_\"}\n" + + "{\"id\":\"test-rule-2\",\"name\":\"Another Rule\",\"pattern\":\"API_KEY_\"}"; + var rulesPath = Path.Combine(bundleDir, "test-bundle.rules.jsonl"); + await File.WriteAllTextAsync(rulesPath, rulesContent); + + // Compute digest + var rulesBytes = Encoding.UTF8.GetBytes(rulesContent); + var rulesDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(rulesBytes)).ToLowerInvariant()}"; + + // Create manifest + var manifest = new + { + bundleId = "test-bundle", + bundleType = "secrets", + version = "2026.1.0", + ruleCount = 2, + files = new[] + { + new + { + name = "test-bundle.rules.jsonl", + digest = rulesDigest, + sizeBytes = rulesBytes.Length + } + } + }; + + await File.WriteAllTextAsync( + Path.Combine(bundleDir, "test-bundle.manifest.json"), + JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true })); + + return bundleDir; + } + + private static RuleBundleValidationRequest CreateRequest( + string bundleDir, + string bundleId, + string bundleType, + DsseEnvelope? signatureEnvelope = null, + TrustRootConfig? trustRoots = null, + bool requireSignature = false, + bool forceActivate = false, + string? forceActivateReason = null) + { + return new RuleBundleValidationRequest( + TenantId: "tenant-test", + BundleId: bundleId, + BundleType: bundleType, + Version: "2026.1.0", + BundleDirectory: bundleDir, + CreatedAt: DateTimeOffset.UtcNow, + SignatureEnvelope: signatureEnvelope, + TrustRoots: trustRoots ?? TrustRootConfig.Empty("/tmp"), + RequireSignature: requireSignature, + ForceActivate: forceActivate, + ForceActivateReason: forceActivateReason); + } + + private sealed class CapturingMonotonicityChecker : IVersionMonotonicityChecker + { + public List<(string TenantId, string BundleType, BundleVersion Version)> RecordedActivations { get; } = []; + + public Task CheckAsync( + string tenantId, + string bundleType, + BundleVersion incomingVersion, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new MonotonicityCheckResult( + IsMonotonic: true, + CurrentVersion: null, + CurrentBundleDigest: null, + CurrentActivatedAt: null, + ReasonCode: "FIRST_ACTIVATION")); + } + + public Task RecordActivationAsync( + string tenantId, + string bundleType, + BundleVersion version, + string bundleDigest, + bool wasForceActivated = false, + string? forceActivateReason = null, + CancellationToken cancellationToken = default) + { + RecordedActivations.Add((tenantId, bundleType, version)); + return Task.CompletedTask; + } + } + + private sealed class NonMonotonicChecker : IVersionMonotonicityChecker + { + public Task CheckAsync( + string tenantId, + string bundleType, + BundleVersion incomingVersion, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new MonotonicityCheckResult( + IsMonotonic: false, + CurrentVersion: BundleVersion.Parse("2026.12.0", DateTimeOffset.UtcNow), + CurrentBundleDigest: "sha256:current", + CurrentActivatedAt: DateTimeOffset.UtcNow.AddDays(-1), + ReasonCode: "OLDER_VERSION")); + } + + public Task RecordActivationAsync( + string tenantId, + string bundleType, + BundleVersion version, + string bundleDigest, + bool wasForceActivated = false, + string? forceActivateReason = null, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } +} diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Abstractions/IOfflineRootStore.cs b/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Abstractions/IOfflineRootStore.cs index 41a079b91..11f41b0f2 100644 --- a/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Abstractions/IOfflineRootStore.cs +++ b/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Abstractions/IOfflineRootStore.cs @@ -69,6 +69,18 @@ public interface IOfflineRootStore Task> ListRootsAsync( RootType rootType, CancellationToken cancellationToken = default); + + /// + /// Get a rule bundle signing key by ID and bundle type. + /// + /// The key identifier. + /// The bundle type (e.g., "secrets", "malware"). + /// Cancellation token. + /// The envelope key if found, null otherwise. + Task GetRuleBundleSigningKeyAsync( + string keyId, + string bundleType, + CancellationToken cancellationToken = default); } /// @@ -81,7 +93,9 @@ public enum RootType /// Organization signing keys for bundle endorsement. OrgSigning, /// Rekor public keys for transparency log verification. - Rekor + Rekor, + /// Rule bundle signing keys for secrets/malware rule bundles. + RuleBundleSigning } /// diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Abstractions/IRuleBundleSignatureVerifier.cs b/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Abstractions/IRuleBundleSignatureVerifier.cs new file mode 100644 index 000000000..9a99cc3cf --- /dev/null +++ b/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Abstractions/IRuleBundleSignatureVerifier.cs @@ -0,0 +1,168 @@ +// ----------------------------------------------------------------------------- +// IRuleBundleSignatureVerifier.cs +// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) +// Task: OKS-004 - Add Attestor mirror support for bundle verification +// Description: Interface for verifying rule bundle signatures offline +// ----------------------------------------------------------------------------- + +using StellaOps.Attestor.Offline.Models; + +namespace StellaOps.Attestor.Offline.Abstractions; + +/// +/// Service for verifying rule bundle (secrets, malware, etc.) signatures offline. +/// Enables air-gapped environments to verify rule bundle signatures using +/// locally stored signing keys. +/// +public interface IRuleBundleSignatureVerifier +{ + /// + /// Verify a rule bundle signature. + /// + /// The verification request. + /// Cancellation token. + /// Verification result with detailed status. + Task VerifyAsync( + RuleBundleSignatureRequest request, + CancellationToken cancellationToken = default); + + /// + /// Verify a rule bundle from a directory. + /// + /// Directory containing the rule bundle. + /// Expected bundle identifier. + /// Verification options. + /// Cancellation token. + /// Verification result. + Task VerifyDirectoryAsync( + string bundleDirectory, + string bundleId, + RuleBundleVerificationOptions? options = null, + CancellationToken cancellationToken = default); +} + +/// +/// Request for verifying a rule bundle signature. +/// +public sealed record RuleBundleSignatureRequest +{ + /// + /// The DSSE envelope containing the signature. + /// + public required byte[] EnvelopeBytes { get; init; } + + /// + /// The payload (manifest) that was signed. + /// + public required byte[] PayloadBytes { get; init; } + + /// + /// Expected bundle identifier. + /// + public required string BundleId { get; init; } + + /// + /// Expected bundle type (e.g., "secrets", "malware"). + /// + public required string BundleType { get; init; } + + /// + /// Expected bundle version. + /// + public required string Version { get; init; } + + /// + /// Key ID that should have signed the bundle (optional). + /// + public string? ExpectedKeyId { get; init; } +} + +/// +/// Result of rule bundle signature verification. +/// +public sealed record RuleBundleSignatureResult +{ + /// + /// Whether the signature is valid. + /// + public bool IsValid { get; init; } + + /// + /// Key ID that signed the bundle. + /// + public string? SignerKeyId { get; init; } + + /// + /// Algorithm used for signing. + /// + public string? Algorithm { get; init; } + + /// + /// When the signature was verified. + /// + public DateTimeOffset VerifiedAt { get; init; } + + /// + /// Error message if verification failed. + /// + public string? Error { get; init; } + + /// + /// Detailed verification issues. + /// + public IReadOnlyList Issues { get; init; } = []; + + /// + /// Create a successful result. + /// + public static RuleBundleSignatureResult Success( + string signerKeyId, + string algorithm, + DateTimeOffset verifiedAt) => new() + { + IsValid = true, + SignerKeyId = signerKeyId, + Algorithm = algorithm, + VerifiedAt = verifiedAt + }; + + /// + /// Create a failed result. + /// + public static RuleBundleSignatureResult Failure( + string error, + DateTimeOffset verifiedAt, + IReadOnlyList? issues = null) => new() + { + IsValid = false, + Error = error, + VerifiedAt = verifiedAt, + Issues = issues ?? [] + }; +} + +/// +/// Options for rule bundle verification. +/// +public sealed record RuleBundleVerificationOptions +{ + /// + /// Path to the signing key file. + /// + public string? SigningKeyPath { get; init; } + + /// + /// Expected signer key ID. + /// + public string? ExpectedKeyId { get; init; } + + /// + /// Whether to require a valid signature. + /// + public bool RequireSignature { get; init; } = true; + + /// + /// Whether to use strict mode (fail on any warning). + /// + public bool StrictMode { get; init; } +} diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Services/FileSystemRootStore.cs b/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Services/FileSystemRootStore.cs index 29c1c67df..04d5a870f 100644 --- a/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Services/FileSystemRootStore.cs +++ b/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Services/FileSystemRootStore.cs @@ -8,8 +8,10 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Attestor.Envelope; using StellaOps.Attestor.Offline.Abstractions; namespace StellaOps.Attestor.Offline.Services; @@ -26,6 +28,8 @@ public sealed class FileSystemRootStore : IOfflineRootStore private X509Certificate2Collection? _fulcioRoots; private X509Certificate2Collection? _orgSigningKeys; private X509Certificate2Collection? _rekorKeys; + private X509Certificate2Collection? _ruleBundleSigningKeys; + private readonly Dictionary _ruleBundleKeyCache = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _loadLock = new(1, 1); /// @@ -75,6 +79,20 @@ public sealed class FileSystemRootStore : IOfflineRootStore return _rekorKeys ?? new X509Certificate2Collection(); } + /// + /// Get rule bundle signing key certificates. + /// + public async Task GetRuleBundleSigningKeysAsync( + CancellationToken cancellationToken = default) + { + if (_ruleBundleSigningKeys == null) + { + await LoadRootsAsync(RootType.RuleBundleSigning, cancellationToken); + } + + return _ruleBundleSigningKeys ?? new X509Certificate2Collection(); + } + /// public async Task ImportRootsAsync( string pemPath, @@ -160,6 +178,66 @@ public sealed class FileSystemRootStore : IOfflineRootStore return null; } + /// + public async Task GetRuleBundleSigningKeyAsync( + string keyId, + string bundleType, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + ArgumentException.ThrowIfNullOrWhiteSpace(bundleType); + + // Check cache first + var cacheKey = $"{bundleType}:{keyId}"; + if (_ruleBundleKeyCache.TryGetValue(cacheKey, out var cachedKey)) + { + return cachedKey; + } + + // Load signing keys if not loaded + if (_ruleBundleSigningKeys == null) + { + await LoadRootsAsync(RootType.RuleBundleSigning, cancellationToken); + } + + // Look for the key in the certificate store + if (_ruleBundleSigningKeys != null) + { + foreach (var cert in _ruleBundleSigningKeys) + { + var certKeyId = GetSubjectKeyIdentifier(cert) ?? ComputeThumbprint(cert); + if (certKeyId.Equals(keyId, StringComparison.OrdinalIgnoreCase)) + { + var envelopeKey = CreateEnvelopeKeyFromCertificate(cert); + if (envelopeKey != null) + { + _ruleBundleKeyCache[cacheKey] = envelopeKey; + return envelopeKey; + } + } + } + } + + // Try loading from JSON key file + var jsonKeyPath = GetRuleBundleKeyPath(bundleType, keyId); + if (!string.IsNullOrEmpty(jsonKeyPath) && File.Exists(jsonKeyPath)) + { + var envelopeKey = await LoadEnvelopeKeyFromJsonAsync(jsonKeyPath, cancellationToken); + if (envelopeKey != null) + { + _ruleBundleKeyCache[cacheKey] = envelopeKey; + return envelopeKey; + } + } + + _logger.LogWarning( + "Rule bundle signing key not found: keyId={KeyId} bundleType={BundleType}", + keyId, + bundleType); + + return null; + } + /// public async Task> ListRootsAsync( RootType rootType, @@ -170,6 +248,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore RootType.Fulcio => await GetFulcioRootsAsync(cancellationToken), RootType.OrgSigning => await GetOrgSigningKeysAsync(cancellationToken), RootType.Rekor => await GetRekorKeysAsync(cancellationToken), + RootType.RuleBundleSigning => await GetRuleBundleSigningKeysAsync(cancellationToken), _ => throw new ArgumentOutOfRangeException(nameof(rootType)) }; @@ -297,6 +376,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore RootType.Fulcio => _options.FulcioBundlePath ?? "", RootType.OrgSigning => _options.OrgSigningBundlePath ?? "", RootType.Rekor => _options.RekorBundlePath ?? "", + RootType.RuleBundleSigning => _options.RuleBundleSigningPath ?? "", _ => "" }; @@ -305,6 +385,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore RootType.Fulcio => _options.FulcioBundlePath ?? Path.Combine(_options.BaseRootPath, "fulcio"), RootType.OrgSigning => _options.OrgSigningBundlePath ?? Path.Combine(_options.BaseRootPath, "org-signing"), RootType.Rekor => _options.RekorBundlePath ?? Path.Combine(_options.BaseRootPath, "rekor"), + RootType.RuleBundleSigning => _options.RuleBundleSigningPath ?? Path.Combine(_options.BaseRootPath, "rule-bundle-signing"), _ => _options.BaseRootPath }; @@ -320,6 +401,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore RootType.Fulcio => Path.Combine(_options.OfflineKitPath, "roots", "fulcio"), RootType.OrgSigning => Path.Combine(_options.OfflineKitPath, "roots", "org-signing"), RootType.Rekor => Path.Combine(_options.OfflineKitPath, "roots", "rekor"), + RootType.RuleBundleSigning => Path.Combine(_options.OfflineKitPath, "roots", "rule-bundle-signing"), _ => null }; } @@ -329,6 +411,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore RootType.Fulcio => _fulcioRoots, RootType.OrgSigning => _orgSigningKeys, RootType.Rekor => _rekorKeys, + RootType.RuleBundleSigning => _ruleBundleSigningKeys, _ => null }; @@ -345,6 +428,9 @@ public sealed class FileSystemRootStore : IOfflineRootStore case RootType.Rekor: _rekorKeys = collection; break; + case RootType.RuleBundleSigning: + _ruleBundleSigningKeys = collection; + break; } } @@ -361,9 +447,130 @@ public sealed class FileSystemRootStore : IOfflineRootStore case RootType.Rekor: _rekorKeys = null; break; + case RootType.RuleBundleSigning: + _ruleBundleSigningKeys = null; + _ruleBundleKeyCache.Clear(); + break; } } + private string? GetRuleBundleKeyPath(string bundleType, string keyId) + { + var basePath = _options.RuleBundleSigningPath ?? Path.Combine(_options.BaseRootPath, "rule-bundle-signing"); + if (string.IsNullOrEmpty(basePath)) + { + return null; + } + + // Try bundle-type specific path first + var typeSpecificPath = Path.Combine(basePath, bundleType, $"{keyId}.json"); + if (File.Exists(typeSpecificPath)) + { + return typeSpecificPath; + } + + // Fall back to general path + return Path.Combine(basePath, $"{keyId}.json"); + } + + private static async Task LoadEnvelopeKeyFromJsonAsync( + string path, + CancellationToken cancellationToken) + { + try + { + var json = await File.ReadAllTextAsync(path, cancellationToken); + using var doc = JsonDocument.Parse(json); + + var algorithm = doc.RootElement.TryGetProperty("algorithm", out var alg) + ? alg.GetString() ?? "ES256" + : "ES256"; + var keyId = doc.RootElement.TryGetProperty("keyId", out var kid) + ? kid.GetString() ?? "" + : ""; + var publicKeyBase64 = doc.RootElement.TryGetProperty("publicKey", out var pk) + ? pk.GetString() + : null; + + if (string.IsNullOrEmpty(publicKeyBase64)) + { + return null; + } + + var publicKeyBytes = Convert.FromBase64String(publicKeyBase64); + + // Create EnvelopeKey based on algorithm + return algorithm.ToUpperInvariant() switch + { + "ES256" => EnvelopeKey.CreateEcdsaVerifier("ES256", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP256)), + "ES384" => EnvelopeKey.CreateEcdsaVerifier("ES384", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP384)), + "ES512" => EnvelopeKey.CreateEcdsaVerifier("ES512", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP521)), + "ED25519" => EnvelopeKey.CreateEd25519Verifier(publicKeyBytes), + _ => null + }; + } + catch + { + return null; + } + } + + private static ECParameters LoadEcParameters(byte[] publicKey, ECCurve curve) + { + // Assume the key is in uncompressed format (0x04 prefix + X + Y) + var keySize = curve.Oid.Value switch + { + "1.2.840.10045.3.1.7" => 32, // P-256 + "1.3.132.0.34" => 48, // P-384 + "1.3.132.0.35" => 66, // P-521 + _ => 32 + }; + + if (publicKey.Length == 2 * keySize + 1 && publicKey[0] == 0x04) + { + return new ECParameters + { + Curve = curve, + Q = new ECPoint + { + X = publicKey[1..(keySize + 1)], + Y = publicKey[(keySize + 1)..] + } + }; + } + + // Try to parse as SubjectPublicKeyInfo (DER format) + using var ecdsa = ECDsa.Create(); + ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _); + return ecdsa.ExportParameters(false); + } + + private static EnvelopeKey? CreateEnvelopeKeyFromCertificate(X509Certificate2 cert) + { + try + { + using var ecdsa = cert.GetECDsaPublicKey(); + if (ecdsa != null) + { + var parameters = ecdsa.ExportParameters(false); + var algorithmId = parameters.Curve.Oid.Value switch + { + "1.2.840.10045.3.1.7" => "ES256", + "1.3.132.0.34" => "ES384", + "1.3.132.0.35" => "ES512", + _ => "ES256" + }; + return EnvelopeKey.CreateEcdsaVerifier(algorithmId, parameters); + } + } + catch + { + // Swallow and try other key types + } + + return null; + } + private static string ComputeThumbprint(X509Certificate2 cert) { var hash = SHA256.HashData(cert.RawData); @@ -418,6 +625,11 @@ public sealed class OfflineRootStoreOptions /// public string? RekorBundlePath { get; set; } + /// + /// Path to rule bundle signing keys (file or directory). + /// + public string? RuleBundleSigningPath { get; set; } + /// /// Path to Offline Kit installation. /// diff --git a/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Services/RuleBundleSignatureVerifier.cs b/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Services/RuleBundleSignatureVerifier.cs new file mode 100644 index 000000000..61253d5c0 --- /dev/null +++ b/src/Attestor/__Libraries/StellaOps.Attestor.Offline/Services/RuleBundleSignatureVerifier.cs @@ -0,0 +1,346 @@ +// ----------------------------------------------------------------------------- +// RuleBundleSignatureVerifier.cs +// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration) +// Task: OKS-004 - Add Attestor mirror support for bundle verification +// Description: Verifies rule bundle signatures offline +// ----------------------------------------------------------------------------- + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using StellaOps.Attestor.Envelope; +using StellaOps.Attestor.Offline.Abstractions; +using StellaOps.Attestor.Offline.Models; + +namespace StellaOps.Attestor.Offline.Services; + +/// +/// Verifies rule bundle (secrets, malware, etc.) signatures offline. +/// +public sealed class RuleBundleSignatureVerifier : IRuleBundleSignatureVerifier +{ + private readonly IOfflineRootStore _rootStore; + private readonly EnvelopeSignatureService _signatureService = new(); + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + + public RuleBundleSignatureVerifier( + IOfflineRootStore rootStore, + ILogger logger, + TimeProvider? timeProvider = null) + { + _rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + public async Task VerifyAsync( + RuleBundleSignatureRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleId); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleType); + + var verifiedAt = _timeProvider.GetUtcNow(); + var issues = new List(); + + _logger.LogInformation( + "Verifying rule bundle signature: bundle_id={BundleId} bundle_type={BundleType} version={Version}", + request.BundleId, + request.BundleType, + request.Version); + + try + { + // Parse DSSE envelope + DsseEnvelope envelope; + try + { + envelope = ParseDsseEnvelope(request.EnvelopeBytes); + } + catch (Exception ex) + { + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Critical, + "ENVELOPE_PARSE_FAILED", + $"Failed to parse DSSE envelope: {ex.Message}")); + return RuleBundleSignatureResult.Failure( + $"envelope-parse-failed:{ex.GetType().Name.ToLowerInvariant()}", + verifiedAt, + issues); + } + + // Verify payload type + if (envelope.PayloadType != "application/vnd.stellaops.rulebundle.manifest+json" && + envelope.PayloadType != "application/json") + { + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Warning, + "UNEXPECTED_PAYLOAD_TYPE", + $"Unexpected payload type: {envelope.PayloadType}")); + } + + // Verify payload digest matches + var envelopePayloadBytes = Convert.FromBase64String(envelope.Payload); + var envelopePayloadDigest = ComputeSha256Digest(envelopePayloadBytes); + var requestPayloadDigest = ComputeSha256Digest(request.PayloadBytes); + + if (envelopePayloadDigest != requestPayloadDigest) + { + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Critical, + "PAYLOAD_DIGEST_MISMATCH", + $"Envelope payload digest {envelopePayloadDigest} does not match provided payload {requestPayloadDigest}")); + return RuleBundleSignatureResult.Failure( + "payload-digest-mismatch", + verifiedAt, + issues); + } + + // Verify signatures + if (envelope.Signatures.Count == 0) + { + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Critical, + "NO_SIGNATURES", + "DSSE envelope has no signatures")); + return RuleBundleSignatureResult.Failure( + "no-signatures", + verifiedAt, + issues); + } + + // Get the signer key + var signature = envelope.Signatures[0]; + var signerKeyId = signature.KeyId; + + if (request.ExpectedKeyId != null && + !string.Equals(signerKeyId, request.ExpectedKeyId, StringComparison.Ordinal)) + { + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Critical, + "KEYID_MISMATCH", + $"Expected key ID {request.ExpectedKeyId} but got {signerKeyId}")); + return RuleBundleSignatureResult.Failure( + $"keyid-mismatch:expected={request.ExpectedKeyId}:actual={signerKeyId}", + verifiedAt, + issues); + } + + // Look up the signing key from the root store + var signingKey = await _rootStore.GetRuleBundleSigningKeyAsync( + signerKeyId, + request.BundleType, + cancellationToken); + + if (signingKey == null) + { + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Critical, + "KEY_NOT_FOUND", + $"Signing key {signerKeyId} not found in root store for bundle type {request.BundleType}")); + return RuleBundleSignatureResult.Failure( + $"key-not-found:{signerKeyId}", + verifiedAt, + issues); + } + + // Verify the DSSE signature + var signatureBytes = Convert.FromBase64String(signature.Sig); + var dsseSignature = new EnvelopeSignature( + signerKeyId, + signingKey.AlgorithmId, + signatureBytes); + + var verifyResult = _signatureService.VerifyDsse( + envelope.PayloadType, + envelopePayloadBytes, + dsseSignature, + signingKey); + + if (!verifyResult.IsSuccess || !verifyResult.Value) + { + var errorMessage = verifyResult.IsSuccess + ? "Signature verification failed" + : $"Signature verification failed: {verifyResult.Error.Code}"; + + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Critical, + "SIGNATURE_INVALID", + errorMessage)); + return RuleBundleSignatureResult.Failure( + "signature-invalid", + verifiedAt, + issues); + } + + _logger.LogInformation( + "Rule bundle signature verified: bundle_id={BundleId} signer_key_id={SignerKeyId}", + request.BundleId, + signerKeyId); + + return RuleBundleSignatureResult.Success( + signerKeyId, + signingKey.AlgorithmId, + verifiedAt); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to verify rule bundle signature: bundle_id={BundleId}", + request.BundleId); + + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Critical, + "VERIFICATION_ERROR", + $"Verification failed: {ex.Message}")); + + return RuleBundleSignatureResult.Failure( + $"verification-error:{ex.GetType().Name.ToLowerInvariant()}", + verifiedAt, + issues); + } + } + + /// + public async Task VerifyDirectoryAsync( + string bundleDirectory, + string bundleId, + RuleBundleVerificationOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bundleDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(bundleId); + + var verifiedAt = _timeProvider.GetUtcNow(); + var issues = new List(); + options ??= new RuleBundleVerificationOptions(); + + // Find manifest file + var manifestPath = Path.Combine(bundleDirectory, $"{bundleId}.manifest.json"); + if (!File.Exists(manifestPath)) + { + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Critical, + "MANIFEST_NOT_FOUND", + $"Manifest not found at {manifestPath}")); + + if (options.RequireSignature) + { + return RuleBundleSignatureResult.Failure( + "manifest-not-found", + verifiedAt, + issues); + } + + return new RuleBundleSignatureResult + { + IsValid = true, + VerifiedAt = verifiedAt, + Issues = issues + }; + } + + // Find signature file + var signaturePath = Path.Combine(bundleDirectory, $"{bundleId}.manifest.sig"); + if (!File.Exists(signaturePath)) + { + issues.Add(new VerificationIssue( + VerificationIssueSeverity.Warning, + "SIGNATURE_NOT_FOUND", + $"Signature file not found at {signaturePath}")); + + if (options.RequireSignature) + { + return RuleBundleSignatureResult.Failure( + "signature-not-found", + verifiedAt, + issues); + } + + return new RuleBundleSignatureResult + { + IsValid = true, + VerifiedAt = verifiedAt, + Issues = issues + }; + } + + // Read manifest and signature + var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken); + var signatureBytes = await File.ReadAllBytesAsync(signaturePath, cancellationToken); + + // Parse manifest to get bundle type and version + string bundleType; + string version; + try + { + using var doc = JsonDocument.Parse(manifestBytes); + bundleType = doc.RootElement.TryGetProperty("bundleType", out var bt) + ? bt.GetString() ?? "unknown" + : "unknown"; + version = doc.RootElement.TryGetProperty("version", out var v) + ? v.GetString() ?? "0.0" + : "0.0"; + } + catch + { + bundleType = "unknown"; + version = "0.0"; + } + + var request = new RuleBundleSignatureRequest + { + EnvelopeBytes = signatureBytes, + PayloadBytes = manifestBytes, + BundleId = bundleId, + BundleType = bundleType, + Version = version, + ExpectedKeyId = options.ExpectedKeyId + }; + + return await VerifyAsync(request, cancellationToken); + } + + private static DsseEnvelope ParseDsseEnvelope(byte[] envelopeBytes) + { + var json = Encoding.UTF8.GetString(envelopeBytes); + var envelope = JsonSerializer.Deserialize(json, JsonOptions); + return envelope ?? throw new InvalidOperationException("Failed to parse DSSE envelope"); + } + + private static string ComputeSha256Digest(byte[] data) + { + var hash = SHA256.HashData(data); + return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; +} + +/// +/// DSSE envelope structure for parsing. +/// +internal sealed class DsseEnvelope +{ + public string PayloadType { get; set; } = string.Empty; + public string Payload { get; set; } = string.Empty; + public List Signatures { get; set; } = []; +} + +/// +/// DSSE signature structure. +/// +internal sealed class DsseSignature +{ + public string KeyId { get; set; } = string.Empty; + public string Sig { get; set; } = string.Empty; +} diff --git a/src/Scanner/StellaOps.Scanner.Worker/Metrics/ScanMetricsCollector.cs b/src/Scanner/StellaOps.Scanner.Worker/Metrics/ScanMetricsCollector.cs index 3f49729c9..8111fb15e 100644 --- a/src/Scanner/StellaOps.Scanner.Worker/Metrics/ScanMetricsCollector.cs +++ b/src/Scanner/StellaOps.Scanner.Worker/Metrics/ScanMetricsCollector.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; +using StellaOps.Determinism.Abstractions; using StellaOps.Scanner.Storage.Models; using StellaOps.Scanner.Storage.Repositories; @@ -20,6 +21,8 @@ public sealed class ScanMetricsCollector : IDisposable { private readonly IScanMetricsRepository _repository; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; private readonly Guid _scanId; private readonly Guid _tenantId; @@ -58,7 +61,9 @@ public sealed class ScanMetricsCollector : IDisposable Guid tenantId, string artifactDigest, string artifactType, - string scannerVersion) + string scannerVersion, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -67,7 +72,9 @@ public sealed class ScanMetricsCollector : IDisposable _artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest)); _artifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType)); _scannerVersion = scannerVersion ?? throw new ArgumentNullException(nameof(scannerVersion)); - _metricsId = Guid.NewGuid(); + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; + _metricsId = _guidProvider.NewGuid(); } /// @@ -80,7 +87,7 @@ public sealed class ScanMetricsCollector : IDisposable /// public void Start() { - _startedAt = DateTimeOffset.UtcNow; + _startedAt = _timeProvider.GetUtcNow(); _totalStopwatch.Start(); _logger.LogDebug("Started metrics collection for scan {ScanId}", _scanId); } @@ -98,7 +105,7 @@ public sealed class ScanMetricsCollector : IDisposable return NoOpDisposable.Instance; } - var tracker = new PhaseTracker(this, phaseName, DateTimeOffset.UtcNow); + var tracker = new PhaseTracker(this, phaseName, _timeProvider.GetUtcNow()); _phases[phaseName] = tracker; _logger.LogDebug("Started phase {PhaseName} for scan {ScanId}", phaseName, _scanId); return tracker; @@ -138,7 +145,7 @@ public sealed class ScanMetricsCollector : IDisposable _phases.Remove(phaseName); - var finishedAt = DateTimeOffset.UtcNow; + var finishedAt = _timeProvider.GetUtcNow(); var phase = new ExecutionPhase { MetricsId = _metricsId, @@ -214,7 +221,7 @@ public sealed class ScanMetricsCollector : IDisposable public async Task CompleteAsync(CancellationToken cancellationToken = default) { _totalStopwatch.Stop(); - var finishedAt = DateTimeOffset.UtcNow; + var finishedAt = _timeProvider.GetUtcNow(); // Calculate phase timings var phases = BuildPhaseTimings(); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/Falsifiability/FalsifiabilityGenerator.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/Falsifiability/FalsifiabilityGenerator.cs index a732bf702..639b66e2d 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/Falsifiability/FalsifiabilityGenerator.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/Falsifiability/FalsifiabilityGenerator.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using Microsoft.Extensions.Logging; +using StellaOps.Determinism; using StellaOps.Scanner.Explainability.Assumptions; namespace StellaOps.Scanner.Explainability.Falsifiability; @@ -60,10 +61,17 @@ public interface IFalsifiabilityGenerator public sealed class FalsifiabilityGenerator : IFalsifiabilityGenerator { private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; - public FalsifiabilityGenerator(ILogger logger) + public FalsifiabilityGenerator( + ILogger logger, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } /// @@ -164,12 +172,12 @@ public sealed class FalsifiabilityGenerator : IFalsifiabilityGenerator return new FalsifiabilityCriteria { - Id = Guid.NewGuid().ToString("N"), + Id = _guidProvider.NewGuid().ToString("N"), FindingId = input.FindingId, Criteria = [.. criteria], Status = status, Summary = summary, - GeneratedAt = DateTimeOffset.UtcNow + GeneratedAt = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/RiskReport.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/RiskReport.cs index cdfb7b229..8835325c1 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/RiskReport.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/RiskReport.cs @@ -2,6 +2,7 @@ // Copyright (c) StellaOps using System.Collections.Immutable; +using StellaOps.Determinism; using StellaOps.Scanner.Explainability.Assumptions; using StellaOps.Scanner.Explainability.Confidence; using StellaOps.Scanner.Explainability.Falsifiability; @@ -118,10 +119,17 @@ public sealed class RiskReportGenerator : IRiskReportGenerator private const string EngineVersionValue = "1.0.0"; private readonly IEvidenceDensityScorer _scorer; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; - public RiskReportGenerator(IEvidenceDensityScorer scorer) + public RiskReportGenerator( + IEvidenceDensityScorer scorer, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _scorer = scorer; + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } /// @@ -140,7 +148,7 @@ public sealed class RiskReportGenerator : IRiskReportGenerator return new RiskReport { - Id = Guid.NewGuid().ToString("N"), + Id = _guidProvider.NewGuid().ToString("N"), FindingId = input.FindingId, VulnerabilityId = input.VulnerabilityId, PackageName = input.PackageName, @@ -151,7 +159,7 @@ public sealed class RiskReportGenerator : IRiskReportGenerator Explanation = explanation, DetailedNarrative = narrative, RecommendedActions = [.. actions], - GeneratedAt = DateTimeOffset.UtcNow, + GeneratedAt = _timeProvider.GetUtcNow(), EngineVersion = EngineVersionValue }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/StellaOps.Scanner.Explainability.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/StellaOps.Scanner.Explainability.csproj index 1745e392e..630a35819 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/StellaOps.Scanner.Explainability.csproj +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Explainability/StellaOps.Scanner.Explainability.csproj @@ -12,4 +12,8 @@ + + + + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/DockerConnectionTester.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/DockerConnectionTester.cs index dd1f52a9f..9d7074401 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/DockerConnectionTester.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/DockerConnectionTester.cs @@ -16,6 +16,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester private readonly IHttpClientFactory _httpClientFactory; private readonly ICredentialResolver _credentialResolver; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -28,11 +29,13 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester public DockerConnectionTester( IHttpClientFactory httpClientFactory, ICredentialResolver credentialResolver, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _httpClientFactory = httpClientFactory; _credentialResolver = credentialResolver; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task TestAsync( @@ -47,7 +50,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Invalid configuration format", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } @@ -100,7 +103,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester { Success = false, Message = $"Registry accessible but image test failed: {imageTestResult.Message}", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }; } @@ -112,7 +115,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester { Success = true, Message = "Successfully connected to Docker registry", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }; } @@ -125,21 +128,21 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Authentication required - configure credentials", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }, HttpStatusCode.Forbidden => new ConnectionTestResult { Success = false, Message = "Access denied - check permissions", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }, _ => new ConnectionTestResult { Success = false, Message = $"Registry returned {response.StatusCode}", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details } }; @@ -151,7 +154,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester { Success = false, Message = $"Connection failed: {ex.Message}", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } catch (TaskCanceledException) when (!ct.IsCancellationRequested) @@ -160,7 +163,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Connection timed out", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/GitConnectionTester.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/GitConnectionTester.cs index ab23aa8f0..1b79cac42 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/GitConnectionTester.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/GitConnectionTester.cs @@ -16,6 +16,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester private readonly IHttpClientFactory _httpClientFactory; private readonly ICredentialResolver _credentialResolver; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -28,11 +29,13 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester public GitConnectionTester( IHttpClientFactory httpClientFactory, ICredentialResolver credentialResolver, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _httpClientFactory = httpClientFactory; _credentialResolver = credentialResolver; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task TestAsync( @@ -47,7 +50,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Invalid configuration format", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } @@ -126,7 +129,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester { Success = true, Message = "Successfully connected to Git repository", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }; } @@ -139,28 +142,28 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Authentication required - configure credentials", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }, HttpStatusCode.Forbidden => new ConnectionTestResult { Success = false, Message = "Access denied - check token permissions", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }, HttpStatusCode.NotFound => new ConnectionTestResult { Success = false, Message = "Repository not found - check URL and access", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }, _ => new ConnectionTestResult { Success = false, Message = $"Server returned {response.StatusCode}", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details } }; @@ -172,7 +175,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester { Success = false, Message = $"Connection failed: {ex.Message}", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["repositoryUrl"] = config.RepositoryUrl @@ -185,7 +188,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Connection timed out", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } } @@ -202,7 +205,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester { Success = true, Message = "SSH configuration accepted - connection will be validated on first scan", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["repositoryUrl"] = config.RepositoryUrl, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/ZastavaConnectionTester.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/ZastavaConnectionTester.cs index b7c882a9b..54af754ef 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/ZastavaConnectionTester.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/ConnectionTesters/ZastavaConnectionTester.cs @@ -17,6 +17,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester private readonly IHttpClientFactory _httpClientFactory; private readonly ICredentialResolver _credentialResolver; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -29,11 +30,13 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester public ZastavaConnectionTester( IHttpClientFactory httpClientFactory, ICredentialResolver credentialResolver, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _httpClientFactory = httpClientFactory; _credentialResolver = credentialResolver; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task TestAsync( @@ -48,7 +51,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Invalid configuration format", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } @@ -90,7 +93,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester { Success = true, Message = "Successfully connected to registry", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }; } @@ -104,28 +107,28 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Authentication failed - check credentials", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }, HttpStatusCode.Forbidden => new ConnectionTestResult { Success = false, Message = "Access denied - insufficient permissions", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }, HttpStatusCode.NotFound => new ConnectionTestResult { Success = false, Message = "Registry endpoint not found - check URL", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details }, _ => new ConnectionTestResult { Success = false, Message = $"Registry returned {response.StatusCode}", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = details } }; @@ -137,7 +140,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester { Success = false, Message = $"Connection failed: {ex.Message}", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["registryUrl"] = config.RegistryUrl, @@ -151,7 +154,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester { Success = false, Message = "Connection timed out", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["registryUrl"] = config.RegistryUrl, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Cli/CliSourceHandler.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Cli/CliSourceHandler.cs index 98b49e9ec..5a67b6a78 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Cli/CliSourceHandler.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Cli/CliSourceHandler.cs @@ -24,6 +24,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler { private readonly ISourceConfigValidator _configValidator; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -38,10 +39,12 @@ public sealed class CliSourceHandler : ISourceTypeHandler public CliSourceHandler( ISourceConfigValidator configValidator, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _configValidator = configValidator; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -102,7 +105,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler { Success = false, Message = "Invalid configuration", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }); } @@ -112,7 +115,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler { Success = true, Message = "CLI source configuration is valid", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["allowedTools"] = config.AllowedTools, @@ -242,8 +245,8 @@ public sealed class CliSourceHandler : ISourceTypeHandler Token = token, TokenHash = Convert.ToHexString(tokenHash).ToLowerInvariant(), SourceId = source.SourceId, - ExpiresAt = DateTimeOffset.UtcNow.Add(validity), - CreatedAt = DateTimeOffset.UtcNow + ExpiresAt = _timeProvider.GetUtcNow().Add(validity), + CreatedAt = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Docker/DockerSourceHandler.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Docker/DockerSourceHandler.cs index d18e54f00..6dc46bdcc 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Docker/DockerSourceHandler.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Docker/DockerSourceHandler.cs @@ -21,6 +21,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler private readonly ISourceConfigValidator _configValidator; private readonly IImageDiscoveryService _discoveryService; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -38,13 +39,15 @@ public sealed class DockerSourceHandler : ISourceTypeHandler ICredentialResolver credentialResolver, ISourceConfigValidator configValidator, IImageDiscoveryService discoveryService, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _clientFactory = clientFactory; _credentialResolver = credentialResolver; _configValidator = configValidator; _discoveryService = discoveryService; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task> DiscoverTargetsAsync( @@ -136,7 +139,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler // Apply age filter if specified if (imageSpec.MaxAgeHours.HasValue) { - var cutoff = DateTimeOffset.UtcNow.AddHours(-imageSpec.MaxAgeHours.Value); + var cutoff = _timeProvider.GetUtcNow().AddHours(-imageSpec.MaxAgeHours.Value); sortedTags = sortedTags .Where(t => t.LastUpdated == null || t.LastUpdated >= cutoff) .ToList(); @@ -181,7 +184,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler { Success = false, Message = "Invalid configuration", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } @@ -198,7 +201,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler { Success = false, Message = "Registry ping failed", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["registryUrl"] = config.RegistryUrl @@ -216,7 +219,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler { Success = true, Message = "Successfully connected to registry", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["registryUrl"] = config.RegistryUrl, @@ -230,7 +233,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler { Success = true, Message = "Successfully connected to registry", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["registryUrl"] = config.RegistryUrl @@ -244,7 +247,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler { Success = false, Message = $"Connection failed: {ex.Message}", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Git/GitSourceHandler.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Git/GitSourceHandler.cs index 0df2eac0c..734f3c982 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Git/GitSourceHandler.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Git/GitSourceHandler.cs @@ -19,6 +19,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle private readonly ICredentialResolver _credentialResolver; private readonly ISourceConfigValidator _configValidator; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -35,12 +36,14 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle IGitClientFactory gitClientFactory, ICredentialResolver credentialResolver, ISourceConfigValidator configValidator, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _gitClientFactory = gitClientFactory; _credentialResolver = credentialResolver; _configValidator = configValidator; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task> DiscoverTargetsAsync( @@ -160,7 +163,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle { Success = false, Message = "Invalid configuration", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } @@ -176,7 +179,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle { Success = false, Message = "Repository not found or inaccessible", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["repositoryUrl"] = config.RepositoryUrl, @@ -189,7 +192,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle { Success = true, Message = "Successfully connected to repository", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["repositoryUrl"] = config.RepositoryUrl, @@ -206,7 +209,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle { Success = false, Message = $"Connection failed: {ex.Message}", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } } @@ -270,7 +273,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle sender.TryGetProperty("login", out var login) ? login.GetString() : null, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } @@ -303,7 +306,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle ? num.GetInt32().ToString() : "" }, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } @@ -330,7 +333,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle Actor = root.TryGetProperty("user_name", out var userName) ? userName.GetString() : null, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } @@ -361,7 +364,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle ? mrAction.GetString() ?? "" : "" }, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } } @@ -371,7 +374,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle { EventType = "unknown", Reference = "", - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Zastava/ZastavaSourceHandler.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Zastava/ZastavaSourceHandler.cs index 0f2bd755d..4697b14ed 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Zastava/ZastavaSourceHandler.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Handlers/Zastava/ZastavaSourceHandler.cs @@ -20,6 +20,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa private readonly ICredentialResolver _credentialResolver; private readonly ISourceConfigValidator _configValidator; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -36,12 +37,14 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa IRegistryClientFactory clientFactory, ICredentialResolver credentialResolver, ISourceConfigValidator configValidator, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _clientFactory = clientFactory; _credentialResolver = credentialResolver; _configValidator = configValidator; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task> DiscoverTargetsAsync( @@ -167,7 +170,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa { Success = false, Message = "Invalid configuration", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } @@ -183,7 +186,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa { Success = false, Message = "Registry ping failed", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["registryUrl"] = config.RegistryUrl, @@ -199,7 +202,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa { Success = true, Message = "Successfully connected to registry", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["registryUrl"] = config.RegistryUrl, @@ -215,7 +218,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa { Success = false, Message = $"Connection failed: {ex.Message}", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } } @@ -281,7 +284,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa : repository.GetProperty("name").GetString()!, Tag = pushData.TryGetProperty("tag", out var tag) ? tag.GetString() : "latest", Actor = pushData.TryGetProperty("pusher", out var pusher) ? pusher.GetString() : null, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } @@ -309,7 +312,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa ? digest.GetString() : null, Actor = eventData.TryGetProperty("operator", out var op) ? op.GetString() : null, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } @@ -338,7 +341,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa actor.TryGetProperty("name", out var actorName) ? actorName.GetString() : null, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } @@ -347,7 +350,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa { EventType = "unknown", Reference = "", - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Persistence/SbomSourceRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Persistence/SbomSourceRepository.cs index f458c2d79..a5bbba1d8 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Persistence/SbomSourceRepository.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Persistence/SbomSourceRepository.cs @@ -17,12 +17,15 @@ public sealed class SbomSourceRepository : RepositoryBase logger) + ILogger logger, + TimeProvider? timeProvider = null) : base(dataSource, logger) { + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default) @@ -317,7 +320,7 @@ public sealed class SbomSourceRepository : RepositoryBase> GetDueForScheduledRunAsync(CancellationToken ct = default) { - return GetDueScheduledSourcesAsync(DateTimeOffset.UtcNow, 100, ct); + return GetDueScheduledSourcesAsync(_timeProvider.GetUtcNow(), 100, ct); } private void ConfigureSourceParams(NpgsqlCommand cmd, SbomSource source) diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Persistence/SbomSourceRunRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Persistence/SbomSourceRunRepository.cs index 095b9a1da..af9d1da65 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Persistence/SbomSourceRunRepository.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Persistence/SbomSourceRunRepository.cs @@ -16,12 +16,15 @@ public sealed class SbomSourceRunRepository : RepositoryBase logger) + ILogger logger, + TimeProvider? timeProvider = null) : base(dataSource, logger) { + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task GetByIdAsync(Guid runId, CancellationToken ct = default) @@ -188,7 +191,7 @@ public sealed class SbomSourceRunRepository : RepositoryBase { - AddParameter(cmd, "threshold", DateTimeOffset.UtcNow - olderThan); + AddParameter(cmd, "threshold", _timeProvider.GetUtcNow() - olderThan); AddParameter(cmd, "limit", limit); }, MapRun, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SbomSourceService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SbomSourceService.cs index 7c84fab01..062d5c4e9 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SbomSourceService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SbomSourceService.cs @@ -17,19 +17,22 @@ public sealed class SbomSourceService : ISbomSourceService private readonly ISourceConfigValidator _configValidator; private readonly ISourceConnectionTester _connectionTester; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public SbomSourceService( ISbomSourceRepository sourceRepository, ISbomSourceRunRepository runRepository, ISourceConfigValidator configValidator, ISourceConnectionTester connectionTester, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _sourceRepository = sourceRepository; _runRepository = runRepository; _configValidator = configValidator; _connectionTester = connectionTester; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default) @@ -215,7 +218,7 @@ public sealed class SbomSourceService : ISbomSourceService } // Touch updated fields - SetProperty(source, "UpdatedAt", DateTimeOffset.UtcNow); + SetProperty(source, "UpdatedAt", _timeProvider.GetUtcNow()); SetProperty(source, "UpdatedBy", updatedBy); await _sourceRepository.UpdateAsync(source, ct); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SourceConnectionTester.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SourceConnectionTester.cs index fc4b026de..414544518 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SourceConnectionTester.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Services/SourceConnectionTester.cs @@ -12,13 +12,16 @@ public sealed class SourceConnectionTester : ISourceConnectionTester { private readonly IEnumerable _testers; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; public SourceConnectionTester( IEnumerable testers, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _testers = testers; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public Task TestAsync(SbomSource source, CancellationToken ct = default) @@ -42,7 +45,7 @@ public sealed class SourceConnectionTester : ISourceConnectionTester { Success = false, Message = $"No connection tester available for source type {source.SourceType}", - TestedAt = DateTimeOffset.UtcNow + TestedAt = _timeProvider.GetUtcNow() }; } @@ -74,7 +77,7 @@ public sealed class SourceConnectionTester : ISourceConnectionTester { Success = false, Message = $"Connection test error: {ex.Message}", - TestedAt = DateTimeOffset.UtcNow, + TestedAt = _timeProvider.GetUtcNow(), Details = new Dictionary { ["exceptionType"] = ex.GetType().Name diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj index c7ae907cc..7c5698a72 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/StellaOps.Scanner.Sources.csproj @@ -22,5 +22,6 @@ + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/SourceTriggerDispatcher.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/SourceTriggerDispatcher.cs index 80e072ea8..6a8736e4d 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/SourceTriggerDispatcher.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Sources/Triggers/SourceTriggerDispatcher.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using StellaOps.Determinism; using StellaOps.Scanner.Sources.Domain; using StellaOps.Scanner.Sources.Handlers; using StellaOps.Scanner.Sources.Persistence; @@ -15,19 +16,25 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher private readonly IEnumerable _handlers; private readonly IScanJobQueue _scanJobQueue; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; public SourceTriggerDispatcher( ISbomSourceRepository sourceRepository, ISbomSourceRunRepository runRepository, IEnumerable handlers, IScanJobQueue scanJobQueue, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _sourceRepository = sourceRepository; _runRepository = runRepository; _handlers = handlers; _scanJobQueue = scanJobQueue; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public Task DispatchAsync( @@ -40,7 +47,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher { Trigger = trigger, TriggerDetails = triggerDetails, - CorrelationId = Guid.NewGuid().ToString("N") + CorrelationId = _guidProvider.NewGuid().ToString("N") }; return DispatchAsync(sourceId, context, ct); @@ -128,7 +135,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher { run.Complete(); await _runRepository.UpdateAsync(run, ct); - source.RecordSuccessfulRun(DateTimeOffset.UtcNow); + source.RecordSuccessfulRun(_timeProvider.GetUtcNow()); await _sourceRepository.UpdateAsync(source, ct); return new TriggerDispatchResult @@ -170,12 +177,12 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher if (run.ItemsFailed == run.ItemsDiscovered) { run.Fail("All targets failed to queue"); - source.RecordFailedRun(DateTimeOffset.UtcNow, run.ErrorMessage!); + source.RecordFailedRun(_timeProvider.GetUtcNow(), run.ErrorMessage!); } else { run.Complete(); - source.RecordSuccessfulRun(DateTimeOffset.UtcNow); + source.RecordSuccessfulRun(_timeProvider.GetUtcNow()); } await _runRepository.UpdateAsync(run, ct); @@ -195,7 +202,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher run.Fail(ex.Message); await _runRepository.UpdateAsync(run, ct); - source.RecordFailedRun(DateTimeOffset.UtcNow, ex.Message); + source.RecordFailedRun(_timeProvider.GetUtcNow(), ex.Message); await _sourceRepository.UpdateAsync(source, ct); return new TriggerDispatchResult @@ -247,7 +254,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher { Trigger = originalRun.Trigger, TriggerDetails = $"Retry of run {originalRunId}", - CorrelationId = Guid.NewGuid().ToString("N"), + CorrelationId = _guidProvider.NewGuid().ToString("N"), Metadata = new() { ["originalRunId"] = originalRunId.ToString() } }; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/SlicePullService.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/SlicePullService.cs index bb7ab1987..1809aea01 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/SlicePullService.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage.Oci/SlicePullService.cs @@ -61,6 +61,7 @@ public sealed class SlicePullService : IDisposable private readonly OciRegistryAuthorization _authorization; private readonly SlicePullOptions _options; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private readonly Dictionary _cache = new(StringComparer.Ordinal); private readonly Lock _cacheLock = new(); @@ -70,12 +71,14 @@ public sealed class SlicePullService : IDisposable HttpClient httpClient, OciRegistryAuthorization authorization, SlicePullOptions? options = null, - ILogger? logger = null) + ILogger? logger = null, + TimeProvider? timeProvider = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _authorization = authorization ?? throw new ArgumentNullException(nameof(authorization)); _options = options ?? new SlicePullOptions(); _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + _timeProvider = timeProvider ?? TimeProvider.System; _httpClient.Timeout = _options.RequestTimeout; } @@ -211,7 +214,7 @@ public sealed class SlicePullService : IDisposable var dsseLayer = manifest.Layers?.FirstOrDefault(l => l.MediaType == OciMediaTypes.DsseEnvelope); - if (dsseLayer != null && _options.VerifySignature) + if (dsseLayer?.Digest != null && _options.VerifySignature) { var dsseResult = await FetchAndVerifyDsseAsync(reference, dsseLayer.Digest, sliceBytes, cancellationToken) .ConfigureAwait(false); @@ -227,7 +230,7 @@ public sealed class SlicePullService : IDisposable SliceData = sliceData, DsseEnvelope = dsseEnvelope, SignatureVerified = signatureVerified, - ExpiresAt = DateTimeOffset.UtcNow.Add(_options.CacheTtl) + ExpiresAt = _timeProvider.GetUtcNow().Add(_options.CacheTtl) }); } @@ -411,7 +414,7 @@ public sealed class SlicePullService : IDisposable { if (_cache.TryGetValue(key, out cached)) { - if (cached.ExpiresAt > DateTimeOffset.UtcNow) + if (cached.ExpiresAt > _timeProvider.GetUtcNow()) { return true; } diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresFuncProofRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresFuncProofRepository.cs index 8412b8f04..ae4bcb466 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresFuncProofRepository.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresFuncProofRepository.cs @@ -8,6 +8,7 @@ using System.Text.Json; using Npgsql; using NpgsqlTypes; +using StellaOps.Determinism; using StellaOps.Scanner.Storage.Entities; namespace StellaOps.Scanner.Storage.Postgres; @@ -64,10 +65,17 @@ public interface IFuncProofRepository public sealed class PostgresFuncProofRepository : IFuncProofRepository { private readonly NpgsqlDataSource _dataSource; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; - public PostgresFuncProofRepository(NpgsqlDataSource dataSource) + public PostgresFuncProofRepository( + NpgsqlDataSource dataSource, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public async Task StoreAsync(FuncProofDocumentRow document, CancellationToken ct = default) @@ -94,7 +102,7 @@ public sealed class PostgresFuncProofRepository : IFuncProofRepository await using var conn = await _dataSource.OpenConnectionAsync(ct); await using var cmd = new NpgsqlCommand(sql, conn); - var id = document.Id == Guid.Empty ? Guid.NewGuid() : document.Id; + var id = document.Id == Guid.Empty ? _guidProvider.NewGuid() : document.Id; cmd.Parameters.AddWithValue("id", id); cmd.Parameters.AddWithValue("scan_id", document.ScanId); @@ -118,7 +126,7 @@ public sealed class PostgresFuncProofRepository : IFuncProofRepository document.RekorEntryId is null ? DBNull.Value : document.RekorEntryId); cmd.Parameters.AddWithValue("generator_version", document.GeneratorVersion); cmd.Parameters.AddWithValue("generated_at_utc", document.GeneratedAtUtc); - cmd.Parameters.AddWithValue("created_at_utc", DateTimeOffset.UtcNow); + cmd.Parameters.AddWithValue("created_at_utc", _timeProvider.GetUtcNow()); var result = await cmd.ExecuteScalarAsync(ct); return result is Guid returnedId ? returnedId : id; diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresIdempotencyKeyRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresIdempotencyKeyRepository.cs index 2866db042..3761245d1 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresIdempotencyKeyRepository.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/PostgresIdempotencyKeyRepository.cs @@ -8,6 +8,7 @@ using Dapper; using Microsoft.Extensions.Logging; using Npgsql; +using StellaOps.Determinism; using StellaOps.Scanner.Storage.Entities; using StellaOps.Scanner.Storage.Repositories; @@ -20,14 +21,17 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository { private readonly ScannerDataSource _dataSource; private readonly ILogger _logger; + private readonly IGuidProvider _guidProvider; private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema; public PostgresIdempotencyKeyRepository( ScannerDataSource dataSource, - ILogger logger) + ILogger logger, + IGuidProvider? guidProvider = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } /// @@ -68,7 +72,7 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository { if (key.KeyId == Guid.Empty) { - key.KeyId = Guid.NewGuid(); + key.KeyId = _guidProvider.NewGuid(); } var sql = $""" diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresProofSpineRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresProofSpineRepository.cs index c1a026b0d..e2bf7a4fe 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresProofSpineRepository.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/PostgresProofSpineRepository.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Npgsql; +using StellaOps.Determinism; using StellaOps.Infrastructure.Postgres.Repositories; using StellaOps.Replay.Core; using StellaOps.Scanner.ProofSpine; @@ -28,14 +29,17 @@ public sealed class PostgresProofSpineRepository : RepositoryBase logger, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) : base(dataSource, logger) { _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public Task GetByIdAsync(string spineId, CancellationToken cancellationToken = default) @@ -249,7 +253,7 @@ public sealed class PostgresProofSpineRepository : RepositoryBase _logger; + private readonly IGuidProvider _guidProvider; public PostgresScanMetricsRepository( NpgsqlDataSource dataSource, - ILogger logger) + ILogger logger, + IGuidProvider? guidProvider = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } /// @@ -67,7 +71,7 @@ public sealed class PostgresScanMetricsRepository : IScanMetricsRepository await using var cmd = _dataSource.CreateCommand(sql); - var metricsId = metrics.MetricsId == Guid.Empty ? Guid.NewGuid() : metrics.MetricsId; + var metricsId = metrics.MetricsId == Guid.Empty ? _guidProvider.NewGuid() : metrics.MetricsId; cmd.Parameters.AddWithValue("metricsId", metricsId); cmd.Parameters.AddWithValue("scanId", metrics.ScanId); diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/RuntimeEventRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/RuntimeEventRepository.cs index 33afdfd8c..d03a618b4 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/RuntimeEventRepository.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/Repositories/RuntimeEventRepository.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using Npgsql; +using StellaOps.Determinism; using StellaOps.Infrastructure.Postgres.Repositories; using StellaOps.Scanner.Storage.Catalog; using StellaOps.Scanner.Storage.Postgres; @@ -16,10 +17,15 @@ public sealed class RuntimeEventRepository : RepositoryBase private string Table => $"{SchemaName}.runtime_events"; private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly IGuidProvider _guidProvider; - public RuntimeEventRepository(ScannerDataSource dataSource, ILogger logger) + public RuntimeEventRepository( + ScannerDataSource dataSource, + ILogger logger, + IGuidProvider? guidProvider = null) : base(dataSource, logger) { + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public async Task InsertAsync( @@ -52,7 +58,7 @@ public sealed class RuntimeEventRepository : RepositoryBase foreach (var document in documents) { cancellationToken.ThrowIfCancellationRequested(); - var id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id; + var id = string.IsNullOrWhiteSpace(document.Id) ? _guidProvider.NewGuid().ToString("N") : document.Id; var rows = await ExecuteAsync( Tenant, diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj index 7126d56e8..651e1a7a1 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj +++ b/src/Scanner/__Libraries/StellaOps.Scanner.Storage/StellaOps.Scanner.Storage.csproj @@ -28,5 +28,6 @@ + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/StellaOps.Scanner.VulnSurfaces.csproj b/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/StellaOps.Scanner.VulnSurfaces.csproj index dcc0297bf..881d429ff 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/StellaOps.Scanner.VulnSurfaces.csproj +++ b/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/StellaOps.Scanner.VulnSurfaces.csproj @@ -21,5 +21,6 @@ + diff --git a/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/Storage/PostgresVulnSurfaceRepository.cs b/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/Storage/PostgresVulnSurfaceRepository.cs index 38d718dc2..706538d95 100644 --- a/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/Storage/PostgresVulnSurfaceRepository.cs +++ b/src/Scanner/__Libraries/StellaOps.Scanner.VulnSurfaces/Storage/PostgresVulnSurfaceRepository.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Npgsql; +using StellaOps.Determinism; using StellaOps.Scanner.VulnSurfaces.Models; namespace StellaOps.Scanner.VulnSurfaces.Storage; @@ -18,15 +19,18 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository { private readonly NpgsqlDataSource _dataSource; private readonly ILogger _logger; + private readonly IGuidProvider _guidProvider; private readonly int _commandTimeoutSeconds; public PostgresVulnSurfaceRepository( NpgsqlDataSource dataSource, ILogger logger, + IGuidProvider? guidProvider = null, int commandTimeoutSeconds = 30) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; _commandTimeoutSeconds = commandTimeoutSeconds; } @@ -45,7 +49,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository string? attestationDigest, CancellationToken cancellationToken = default) { - var id = Guid.NewGuid(); + var id = _guidProvider.NewGuid(); const string sql = """ INSERT INTO scanner.vuln_surfaces ( @@ -106,7 +110,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository string? fixedHash, CancellationToken cancellationToken = default) { - var id = Guid.NewGuid(); + var id = _guidProvider.NewGuid(); const string sql = """ INSERT INTO scanner.vuln_surface_sinks ( @@ -148,7 +152,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository double confidence, CancellationToken cancellationToken = default) { - var id = Guid.NewGuid(); + var id = _guidProvider.NewGuid(); const string sql = """ INSERT INTO scanner.vuln_surface_triggers ( diff --git a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Auditing/InMemorySignerAuditSink.cs b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Auditing/InMemorySignerAuditSink.cs index 4015d0d08..3cf7075fa 100644 --- a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Auditing/InMemorySignerAuditSink.cs +++ b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Auditing/InMemorySignerAuditSink.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using StellaOps.Determinism; using StellaOps.Signer.Core; namespace StellaOps.Signer.Infrastructure.Auditing; @@ -11,11 +12,16 @@ public sealed class InMemorySignerAuditSink : ISignerAuditSink { private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; private readonly ILogger _logger; - public InMemorySignerAuditSink(TimeProvider timeProvider, ILogger logger) + public InMemorySignerAuditSink( + TimeProvider timeProvider, + ILogger logger, + IGuidProvider? guidProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -30,7 +36,7 @@ public sealed class InMemorySignerAuditSink : ISignerAuditSink ArgumentNullException.ThrowIfNull(entitlement); ArgumentNullException.ThrowIfNull(caller); - var auditId = Guid.NewGuid().ToString("d"); + var auditId = _guidProvider.NewGuid().ToString("d"); var entry = new SignerAuditEntry( auditId, _timeProvider.GetUtcNow(), diff --git a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Signing/DefaultSigningKeyResolver.cs b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Signing/DefaultSigningKeyResolver.cs index 1b8b1e7b8..40bd265cc 100644 --- a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Signing/DefaultSigningKeyResolver.cs +++ b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Signing/DefaultSigningKeyResolver.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Determinism; using StellaOps.Signer.Core; namespace StellaOps.Signer.Infrastructure.Signing; @@ -17,15 +18,18 @@ public sealed class DefaultSigningKeyResolver : ISigningKeyResolver private readonly DsseSignerOptions _options; private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; private readonly ILogger _logger; public DefaultSigningKeyResolver( IOptions options, TimeProvider timeProvider, - ILogger logger) + ILogger logger, + IGuidProvider? guidProvider = null) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -56,7 +60,7 @@ public sealed class DefaultSigningKeyResolver : ISigningKeyResolver { // Generate ephemeral key identifier using timestamp for uniqueness var now = _timeProvider.GetUtcNow(); - var keyId = $"{KeylessKeyIdPrefix}{tenant}:{now:yyyyMMddHHmmss}:{Guid.NewGuid():N}"; + var keyId = $"{KeylessKeyIdPrefix}{tenant}:{now:yyyyMMddHHmmss}:{_guidProvider.NewGuid():N}"; var expiresAt = now.AddMinutes(KeylessExpiryMinutes); return new SigningKeyResolution( diff --git a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Sigstore/SigstoreSigningService.cs b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Sigstore/SigstoreSigningService.cs index 0525b5f77..1c5c04fa4 100644 --- a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Sigstore/SigstoreSigningService.cs +++ b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/Sigstore/SigstoreSigningService.cs @@ -18,17 +18,20 @@ public sealed class SigstoreSigningService : ISigstoreSigningService private readonly IFulcioClient _fulcioClient; private readonly IRekorClient _rekorClient; private readonly SigstoreOptions _options; + private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public SigstoreSigningService( IFulcioClient fulcioClient, IRekorClient rekorClient, IOptions options, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _fulcioClient = fulcioClient ?? throw new ArgumentNullException(nameof(fulcioClient)); _rekorClient = rekorClient ?? throw new ArgumentNullException(nameof(rekorClient)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -133,7 +136,7 @@ public sealed class SigstoreSigningService : ISigstoreSigningService } // 3. Check certificate validity - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); if (now < cert.NotBefore || now > cert.NotAfter) { _logger.LogWarning( diff --git a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj index 0716d53d4..6f736a41f 100644 --- a/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj +++ b/src/Signer/StellaOps.Signer/StellaOps.Signer.Infrastructure/StellaOps.Signer.Infrastructure.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/Endpoints/KeyRotationEndpoints.cs b/src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/Endpoints/KeyRotationEndpoints.cs index 06d968018..643df9f6a 100644 --- a/src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/Endpoints/KeyRotationEndpoints.cs +++ b/src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/Endpoints/KeyRotationEndpoints.cs @@ -145,6 +145,7 @@ public static class KeyRotationEndpoints [FromBody] RevokeKeyRequestDto request, IKeyRotationService rotationService, ILoggerFactory loggerFactory, + TimeProvider timeProvider, CancellationToken ct) { var logger = loggerFactory.CreateLogger("KeyRotationEndpoints.RevokeKey"); @@ -183,7 +184,7 @@ public static class KeyRotationEndpoints { KeyId = keyId, AnchorId = anchorId, - RevokedAt = request.EffectiveAt ?? DateTimeOffset.UtcNow, + RevokedAt = request.EffectiveAt ?? timeProvider.GetUtcNow(), Reason = request.Reason, AllowedKeyIds = result.AllowedKeyIds.ToList(), RevokedKeyIds = result.RevokedKeyIds.ToList(), @@ -217,9 +218,10 @@ public static class KeyRotationEndpoints [FromRoute] string keyId, [FromQuery] DateTimeOffset? signedAt, IKeyRotationService rotationService, + TimeProvider timeProvider, CancellationToken ct) { - var checkTime = signedAt ?? DateTimeOffset.UtcNow; + var checkTime = signedAt ?? timeProvider.GetUtcNow(); try { diff --git a/src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/Program.cs b/src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/Program.cs index 954f3b18c..a8d466e53 100644 --- a/src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/Program.cs +++ b/src/Signer/StellaOps.Signer/StellaOps.Signer.WebService/Program.cs @@ -17,8 +17,14 @@ builder.Services.AddAuthentication(StubBearerAuthenticationDefaults.Authenticati builder.Services.AddAuthorization(); builder.Services.AddSignerPipeline(); + +// Configure TimeProvider for deterministic testing support +builder.Services.AddSingleton(TimeProvider.System); + builder.Services.Configure(options => { + // Note: Using 1-hour expiry for demo/test tokens. + // Actual expiry is calculated at runtime relative to TimeProvider. options.Tokens["valid-poe"] = new SignerEntitlementDefinition( LicenseId: "LIC-TEST", CustomerId: "CUST-TEST", diff --git a/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/KeyRotationService.cs b/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/KeyRotationService.cs index 354942ecc..97309456f 100644 --- a/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/KeyRotationService.cs +++ b/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/KeyRotationService.cs @@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Determinism; using StellaOps.Signer.KeyManagement.Entities; namespace StellaOps.Signer.KeyManagement; @@ -22,17 +23,20 @@ public sealed class KeyRotationService : IKeyRotationService private readonly ILogger _logger; private readonly KeyRotationOptions _options; private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; public KeyRotationService( KeyManagementDbContext dbContext, ILogger logger, IOptions options, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options?.Value ?? new KeyRotationOptions(); _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } /// @@ -85,7 +89,7 @@ public sealed class KeyRotationService : IKeyRotationService // Create key history entry var keyEntry = new KeyHistoryEntity { - HistoryId = Guid.NewGuid(), + HistoryId = _guidProvider.NewGuid(), AnchorId = anchorId, KeyId = request.KeyId, PublicKey = request.PublicKey, @@ -106,7 +110,7 @@ public sealed class KeyRotationService : IKeyRotationService // Create audit log entry var auditEntry = new KeyAuditLogEntity { - LogId = Guid.NewGuid(), + LogId = _guidProvider.NewGuid(), AnchorId = anchorId, KeyId = request.KeyId, Operation = KeyOperation.Add, @@ -209,7 +213,7 @@ public sealed class KeyRotationService : IKeyRotationService // Create audit log entry var auditEntry = new KeyAuditLogEntity { - LogId = Guid.NewGuid(), + LogId = _guidProvider.NewGuid(), AnchorId = anchorId, KeyId = keyId, Operation = KeyOperation.Revoke, diff --git a/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/StellaOps.Signer.KeyManagement.csproj b/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/StellaOps.Signer.KeyManagement.csproj index 0ae41ecc9..8303b32d9 100644 --- a/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/StellaOps.Signer.KeyManagement.csproj +++ b/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/StellaOps.Signer.KeyManagement.csproj @@ -15,6 +15,10 @@ + + + + PreserveNewest diff --git a/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/TrustAnchorManager.cs b/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/TrustAnchorManager.cs index 87502a6e0..a9e741605 100644 --- a/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/TrustAnchorManager.cs +++ b/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/TrustAnchorManager.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using StellaOps.Determinism; using StellaOps.Signer.KeyManagement.Entities; namespace StellaOps.Signer.KeyManagement; @@ -22,17 +23,20 @@ public sealed class TrustAnchorManager : ITrustAnchorManager private readonly IKeyRotationService _keyRotationService; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; public TrustAnchorManager( KeyManagementDbContext dbContext, IKeyRotationService keyRotationService, ILogger logger, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _keyRotationService = keyRotationService ?? throw new ArgumentNullException(nameof(keyRotationService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } /// @@ -115,7 +119,7 @@ public sealed class TrustAnchorManager : ITrustAnchorManager var entity = new TrustAnchorEntity { - AnchorId = Guid.NewGuid(), + AnchorId = _guidProvider.NewGuid(), PurlPattern = request.PurlPattern, AllowedKeyIds = request.AllowedKeyIds?.ToList() ?? [], AllowedPredicateTypes = request.AllowedPredicateTypes?.ToList(), diff --git a/src/Signer/__Libraries/StellaOps.Signer.Keyless/AmbientOidcTokenProvider.cs b/src/Signer/__Libraries/StellaOps.Signer.Keyless/AmbientOidcTokenProvider.cs index 4d764944a..ba3cf58f6 100644 --- a/src/Signer/__Libraries/StellaOps.Signer.Keyless/AmbientOidcTokenProvider.cs +++ b/src/Signer/__Libraries/StellaOps.Signer.Keyless/AmbientOidcTokenProvider.cs @@ -21,13 +21,15 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable private readonly JwtSecurityTokenHandler _tokenHandler; private readonly SemaphoreSlim _lock = new(1, 1); private readonly FileSystemWatcher? _watcher; + private readonly TimeProvider _timeProvider; private OidcTokenResult? _cachedToken; private bool _disposed; public AmbientOidcTokenProvider( OidcAmbientConfig config, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { ArgumentNullException.ThrowIfNull(config); ArgumentNullException.ThrowIfNull(logger); @@ -35,6 +37,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable _config = config; _logger = logger; _tokenHandler = new JwtSecurityTokenHandler(); + _timeProvider = timeProvider ?? TimeProvider.System; if (_config.WatchForChanges && File.Exists(_config.TokenPath)) { @@ -65,7 +68,8 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable try { // Check cache first - if (_cachedToken is not null && !_cachedToken.WillExpireSoon(TimeSpan.FromSeconds(30))) + var now = _timeProvider.GetUtcNow(); + if (_cachedToken is not null && !_cachedToken.WillExpireSoon(now, TimeSpan.FromSeconds(30))) { return _cachedToken; } @@ -111,7 +115,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable public OidcTokenResult? GetCachedToken() { var cached = _cachedToken; - if (cached is null || cached.IsExpired) + if (cached is null || cached.IsExpiredAt(_timeProvider.GetUtcNow())) { return null; } @@ -132,7 +136,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable var expiresAt = jwt.ValidTo != DateTime.MinValue ? new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero) - : DateTimeOffset.UtcNow.AddHours(1); // Default if no exp claim + : _timeProvider.GetUtcNow().AddHours(1); // Default if no exp claim var subject = jwt.Subject; var email = jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value; diff --git a/src/Signer/__Libraries/StellaOps.Signer.Keyless/EphemeralKeyPair.cs b/src/Signer/__Libraries/StellaOps.Signer.Keyless/EphemeralKeyPair.cs index 38230fcc8..659db2b15 100644 --- a/src/Signer/__Libraries/StellaOps.Signer.Keyless/EphemeralKeyPair.cs +++ b/src/Signer/__Libraries/StellaOps.Signer.Keyless/EphemeralKeyPair.cs @@ -47,7 +47,8 @@ public sealed class EphemeralKeyPair : IDisposable /// The public key bytes. /// The private key bytes (will be copied). /// The algorithm identifier. - public EphemeralKeyPair(byte[] publicKey, byte[] privateKey, string algorithm) + /// Optional time provider for deterministic timestamp. + public EphemeralKeyPair(byte[] publicKey, byte[] privateKey, string algorithm, TimeProvider? timeProvider = null) { ArgumentNullException.ThrowIfNull(publicKey); ArgumentNullException.ThrowIfNull(privateKey); @@ -56,7 +57,7 @@ public sealed class EphemeralKeyPair : IDisposable _publicKey = (byte[])publicKey.Clone(); _privateKey = (byte[])privateKey.Clone(); Algorithm = algorithm; - CreatedAt = DateTimeOffset.UtcNow; + CreatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow(); } /// diff --git a/src/Signer/__Libraries/StellaOps.Signer.Keyless/IFulcioClient.cs b/src/Signer/__Libraries/StellaOps.Signer.Keyless/IFulcioClient.cs index b2fb7cd65..8d23590b8 100644 --- a/src/Signer/__Libraries/StellaOps.Signer.Keyless/IFulcioClient.cs +++ b/src/Signer/__Libraries/StellaOps.Signer.Keyless/IFulcioClient.cs @@ -75,9 +75,11 @@ public sealed record FulcioCertificateResult( public TimeSpan Validity => NotAfter - NotBefore; /// - /// Checks if the certificate is currently valid. + /// Checks if the certificate is valid at the specified time. /// - public bool IsValid => DateTimeOffset.UtcNow >= NotBefore && DateTimeOffset.UtcNow <= NotAfter; + /// The time to check validity against. + /// True if the certificate is valid at the specified time. + public bool IsValidAt(DateTimeOffset at) => at >= NotBefore && at <= NotAfter; /// /// Gets the full certificate chain including the leaf certificate. diff --git a/src/Signer/__Libraries/StellaOps.Signer.Keyless/IOidcTokenProvider.cs b/src/Signer/__Libraries/StellaOps.Signer.Keyless/IOidcTokenProvider.cs index a247f6e69..085678a21 100644 --- a/src/Signer/__Libraries/StellaOps.Signer.Keyless/IOidcTokenProvider.cs +++ b/src/Signer/__Libraries/StellaOps.Signer.Keyless/IOidcTokenProvider.cs @@ -62,15 +62,20 @@ public sealed record OidcTokenResult public string? Email { get; init; } /// - /// Whether the token is expired. + /// Checks whether the token is expired at the specified time. /// - public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt; + /// The time to check against. + /// True if the token is expired. + public bool IsExpiredAt(DateTimeOffset now) => now >= ExpiresAt; /// - /// Whether the token will expire within the specified buffer time. + /// Checks whether the token will expire within the specified buffer time. /// - public bool WillExpireSoon(TimeSpan buffer) => - DateTimeOffset.UtcNow.Add(buffer) >= ExpiresAt; + /// The current time. + /// The time buffer before expiration. + /// True if the token will expire soon. + public bool WillExpireSoon(DateTimeOffset now, TimeSpan buffer) => + now.Add(buffer) >= ExpiresAt; } /// diff --git a/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresConsensusProjectionStore.cs b/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresConsensusProjectionStore.cs index c3a751915..214e9c3dd 100644 --- a/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresConsensusProjectionStore.cs +++ b/src/VexLens/StellaOps.VexLens.Persistence/Postgres/PostgresConsensusProjectionStore.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Npgsql; +using StellaOps.Determinism; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; using StellaOps.VexLens.Storage; @@ -26,16 +27,19 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore private readonly IConsensusEventEmitter? _eventEmitter; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; public PostgresConsensusProjectionStore( NpgsqlDataSource dataSource, ILogger logger, TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null, IConsensusEventEmitter? eventEmitter = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? new SystemGuidProvider(); _eventEmitter = eventEmitter; } @@ -52,7 +56,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore activity?.SetTag("vulnerabilityId", result.VulnerabilityId); activity?.SetTag("productKey", result.ProductKey); - var projectionId = Guid.NewGuid(); + var projectionId = _guidProvider.NewGuid(); var now = _timeProvider.GetUtcNow(); // Check for previous projection to track history @@ -527,7 +531,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore // Always emit computed event await _eventEmitter.EmitConsensusComputedAsync( new ConsensusComputedEvent( - EventId: Guid.NewGuid().ToString(), + EventId: _guidProvider.NewGuid().ToString(), ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, @@ -546,7 +550,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore { await _eventEmitter.EmitStatusChangedAsync( new ConsensusStatusChangedEvent( - EventId: Guid.NewGuid().ToString(), + EventId: _guidProvider.NewGuid().ToString(), ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, @@ -564,7 +568,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore { await _eventEmitter.EmitConflictDetectedAsync( new ConsensusConflictDetectedEvent( - EventId: Guid.NewGuid().ToString(), + EventId: _guidProvider.NewGuid().ToString(), ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, diff --git a/src/VexLens/StellaOps.VexLens.Persistence/StellaOps.VexLens.Persistence.csproj b/src/VexLens/StellaOps.VexLens.Persistence/StellaOps.VexLens.Persistence.csproj index aeec85e52..6a1d6144c 100644 --- a/src/VexLens/StellaOps.VexLens.Persistence/StellaOps.VexLens.Persistence.csproj +++ b/src/VexLens/StellaOps.VexLens.Persistence/StellaOps.VexLens.Persistence.csproj @@ -18,6 +18,7 @@ + diff --git a/src/VexLens/StellaOps.VexLens.WebService/Extensions/VexLensEndpointExtensions.cs b/src/VexLens/StellaOps.VexLens.WebService/Extensions/VexLensEndpointExtensions.cs index 8523a50cf..d92b32460 100644 --- a/src/VexLens/StellaOps.VexLens.WebService/Extensions/VexLensEndpointExtensions.cs +++ b/src/VexLens/StellaOps.VexLens.WebService/Extensions/VexLensEndpointExtensions.cs @@ -325,6 +325,7 @@ public static class VexLensEndpointExtensions [FromQuery] DateTimeOffset? fromDate, [FromQuery] DateTimeOffset? toDate, [FromServices] IGatingStatisticsStore statsStore, + [FromServices] TimeProvider timeProvider, HttpContext context, CancellationToken cancellationToken) { @@ -340,7 +341,7 @@ public static class VexLensEndpointExtensions TotalSurfaced: stats.TotalSurfaced, TotalDamped: stats.TotalDamped, AverageDampingPercent: stats.AverageDampingPercent, - ComputedAt: DateTimeOffset.UtcNow)); + ComputedAt: timeProvider.GetUtcNow())); } private static async Task GateSnapshotAsync( diff --git a/src/VexLens/StellaOps.VexLens/Api/IConsensusRationaleService.cs b/src/VexLens/StellaOps.VexLens/Api/IConsensusRationaleService.cs index 24aeb73db..a466a00ee 100644 --- a/src/VexLens/StellaOps.VexLens/Api/IConsensusRationaleService.cs +++ b/src/VexLens/StellaOps.VexLens/Api/IConsensusRationaleService.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using StellaOps.Determinism; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; using StellaOps.VexLens.Storage; @@ -43,17 +44,20 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService private readonly IConsensusProjectionStore _projectionStore; private readonly IVexConsensusEngine _consensusEngine; private readonly ITrustWeightEngine _trustWeightEngine; + private readonly IGuidProvider _guidProvider; private const string AlgorithmVersion = "1.0.0"; public ConsensusRationaleService( IConsensusProjectionStore projectionStore, IVexConsensusEngine consensusEngine, - ITrustWeightEngine trustWeightEngine) + ITrustWeightEngine trustWeightEngine, + IGuidProvider? guidProvider = null) { _projectionStore = projectionStore; _consensusEngine = consensusEngine; _trustWeightEngine = trustWeightEngine; + _guidProvider = guidProvider ?? new SystemGuidProvider(); } public async Task GenerateRationaleAsync( @@ -177,7 +181,7 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService var outputHash = ComputeOutputHash(result, contributions, conflicts); var rationale = new DetailedConsensusRationale( - RationaleId: $"rat-{Guid.NewGuid():N}", + RationaleId: $"rat-{_guidProvider.NewGuid():N}", VulnerabilityId: result.VulnerabilityId, ProductKey: result.ProductKey, ConsensusStatus: result.ConsensusStatus, diff --git a/src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs b/src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs index d12f2a697..6c0c65fe1 100644 --- a/src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs +++ b/src/VexLens/StellaOps.VexLens/Api/IVexLensApiService.cs @@ -137,19 +137,22 @@ public sealed class VexLensApiService : IVexLensApiService private readonly IConsensusProjectionStore _projectionStore; private readonly IIssuerDirectory _issuerDirectory; private readonly IVexStatementProvider _statementProvider; + private readonly TimeProvider _timeProvider; public VexLensApiService( IVexConsensusEngine consensusEngine, ITrustWeightEngine trustWeightEngine, IConsensusProjectionStore projectionStore, IIssuerDirectory issuerDirectory, - IVexStatementProvider statementProvider) + IVexStatementProvider statementProvider, + TimeProvider? timeProvider = null) { _consensusEngine = consensusEngine; _trustWeightEngine = trustWeightEngine; _projectionStore = projectionStore; _issuerDirectory = issuerDirectory; _statementProvider = statementProvider; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task ComputeConsensusAsync( @@ -164,7 +167,7 @@ public sealed class VexLensApiService : IVexLensApiService cancellationToken); // Compute trust weights - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var weightedStatements = new List(); foreach (var stmt in statements) @@ -237,7 +240,7 @@ public sealed class VexLensApiService : IVexLensApiService cancellationToken); // Compute trust weights - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var weightedStatements = new List(); foreach (var stmt in statements) @@ -293,7 +296,7 @@ public sealed class VexLensApiService : IVexLensApiService var resolutionResult = await _consensusEngine.ComputeConsensusWithProofAsync( consensusRequest, proofContext, - TimeProvider.System, + _timeProvider, cancellationToken); // Store result if requested @@ -348,7 +351,7 @@ public sealed class VexLensApiService : IVexLensApiService TotalCount: request.Targets.Count, SuccessCount: results.Count, FailureCount: failures, - CompletedAt: DateTimeOffset.UtcNow); + CompletedAt: _timeProvider.GetUtcNow()); } public async Task GetProjectionAsync( @@ -452,7 +455,7 @@ public sealed class VexLensApiService : IVexLensApiService var withConflicts = projections.Count(p => p.ConflictCount > 0); - var last24h = DateTimeOffset.UtcNow.AddDays(-1); + var last24h = _timeProvider.GetUtcNow().AddDays(-1); var changesLast24h = projections.Count(p => p.StatusChanged && p.ComputedAt >= last24h); return new ConsensusStatisticsResponse( @@ -462,7 +465,7 @@ public sealed class VexLensApiService : IVexLensApiService AverageConfidence: avgConfidence, ProjectionsWithConflicts: withConflicts, StatusChangesLast24h: changesLast24h, - ComputedAt: DateTimeOffset.UtcNow); + ComputedAt: _timeProvider.GetUtcNow()); } public async Task ListIssuersAsync( diff --git a/src/VexLens/StellaOps.VexLens/Api/TrustScorecardApiModels.cs b/src/VexLens/StellaOps.VexLens/Api/TrustScorecardApiModels.cs index 342d4b559..af8daf8c1 100644 --- a/src/VexLens/StellaOps.VexLens/Api/TrustScorecardApiModels.cs +++ b/src/VexLens/StellaOps.VexLens/Api/TrustScorecardApiModels.cs @@ -472,15 +472,18 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService private readonly ISourceTrustScoreCalculator _scoreCalculator; private readonly IConflictAuditStore? _auditStore; private readonly ITrustScoreHistoryStore? _historyStore; + private readonly TimeProvider _timeProvider; public TrustScorecardApiService( ISourceTrustScoreCalculator scoreCalculator, IConflictAuditStore? auditStore = null, - ITrustScoreHistoryStore? historyStore = null) + ITrustScoreHistoryStore? historyStore = null, + TimeProvider? timeProvider = null) { _scoreCalculator = scoreCalculator; _auditStore = auditStore; _historyStore = historyStore; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task GetScorecardAsync( @@ -544,7 +547,7 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService SignatureValidityRate = cachedScore.Breakdown.Verification.SignatureValidityRate, VerificationMethod = cachedScore.Breakdown.Verification.IssuerVerified ? "registry" : null }, - GeneratedAt = DateTimeOffset.UtcNow + GeneratedAt = _timeProvider.GetUtcNow() }; } @@ -604,10 +607,11 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService }; } + var now = _timeProvider.GetUtcNow(); var history = await _historyStore.GetHistoryAsync( sourceId, - DateTimeOffset.UtcNow.AddDays(-days), - DateTimeOffset.UtcNow, + now.AddDays(-days), + now, cancellationToken); if (history.Count == 0) @@ -622,7 +626,7 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService var current = history.LastOrDefault()?.CompositeScore ?? 0.0; var thirtyDaysAgo = history - .Where(h => h.Timestamp >= DateTimeOffset.UtcNow.AddDays(-30)) + .Where(h => h.Timestamp >= now.AddDays(-30)) .FirstOrDefault()?.CompositeScore ?? current; var ninetyDaysAgo = history.FirstOrDefault()?.CompositeScore ?? current; diff --git a/src/VexLens/StellaOps.VexLens/Caching/IConsensusRationaleCache.cs b/src/VexLens/StellaOps.VexLens/Caching/IConsensusRationaleCache.cs index 7d93e4867..ae2570189 100644 --- a/src/VexLens/StellaOps.VexLens/Caching/IConsensusRationaleCache.cs +++ b/src/VexLens/StellaOps.VexLens/Caching/IConsensusRationaleCache.cs @@ -138,14 +138,16 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache private readonly Dictionary _cache = new(); private readonly object _lock = new(); private readonly int _maxEntries; + private readonly TimeProvider _timeProvider; private long _hitCount; private long _missCount; private DateTimeOffset? _lastCleared; - public InMemoryConsensusRationaleCache(int maxEntries = 10000) + public InMemoryConsensusRationaleCache(int maxEntries = 10000, TimeProvider? timeProvider = null) { _maxEntries = maxEntries; + _timeProvider = timeProvider ?? TimeProvider.System; } public Task GetAsync( @@ -163,7 +165,7 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache return Task.FromResult(null); } - entry.LastAccessed = DateTimeOffset.UtcNow; + entry.LastAccessed = _timeProvider.GetUtcNow(); Interlocked.Increment(ref _hitCount); return Task.FromResult(entry.Rationale); } @@ -187,12 +189,13 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache EvictOldestEntry(); } + var now = _timeProvider.GetUtcNow(); _cache[cacheKey] = new CacheEntry { Rationale = rationale, Options = options ?? new CacheOptions(), - Created = DateTimeOffset.UtcNow, - LastAccessed = DateTimeOffset.UtcNow + Created = now, + LastAccessed = now }; return Task.CompletedTask; @@ -254,7 +257,7 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache lock (_lock) { _cache.Clear(); - _lastCleared = DateTimeOffset.UtcNow; + _lastCleared = _timeProvider.GetUtcNow(); return Task.CompletedTask; } } @@ -277,9 +280,9 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache } } - private static bool IsExpired(CacheEntry entry) + private bool IsExpired(CacheEntry entry) { - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); if (entry.Options.AbsoluteExpiration.HasValue && now >= entry.Options.AbsoluteExpiration.Value) diff --git a/src/VexLens/StellaOps.VexLens/Consensus/VexConsensusEngine.cs b/src/VexLens/StellaOps.VexLens/Consensus/VexConsensusEngine.cs index 0c4422ba7..40e634aee 100644 --- a/src/VexLens/StellaOps.VexLens/Consensus/VexConsensusEngine.cs +++ b/src/VexLens/StellaOps.VexLens/Consensus/VexConsensusEngine.cs @@ -13,10 +13,14 @@ namespace StellaOps.VexLens.Consensus; public sealed class VexConsensusEngine : IVexConsensusEngine { private ConsensusConfiguration _configuration; + private readonly TimeProvider _timeProvider; - public VexConsensusEngine(ConsensusConfiguration? configuration = null) + public VexConsensusEngine( + ConsensusConfiguration? configuration = null, + TimeProvider? timeProvider = null) { _configuration = configuration ?? CreateDefaultConfiguration(); + _timeProvider = timeProvider ?? TimeProvider.System; } public Task ComputeConsensusAsync( @@ -559,7 +563,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine stmt.Statement.Status, stmt.Statement.Justification, weight, - GetStatementTimestamp(stmt.Statement), + GetStatementTimestamp(stmt.Statement, _timeProvider), HasSignature(stmt.Weight)); } @@ -574,7 +578,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine stmt.Statement.Status, stmt.Statement.Justification, weight, - GetStatementTimestamp(stmt.Statement), + GetStatementTimestamp(stmt.Statement, _timeProvider), HasSignature(stmt.Weight), reason); } @@ -704,7 +708,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine stmt.Statement.Status, stmt.Statement.Justification, weight, - GetStatementTimestamp(stmt.Statement), + GetStatementTimestamp(stmt.Statement, _timeProvider), HasSignature(stmt.Weight)); } @@ -719,7 +723,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine stmt.Statement.Status, stmt.Statement.Justification, weight, - GetStatementTimestamp(stmt.Statement), + GetStatementTimestamp(stmt.Statement, _timeProvider), HasSignature(stmt.Weight), reason); } @@ -1278,10 +1282,10 @@ public sealed class VexConsensusEngine : IVexConsensusEngine (decimal)breakdown.StatusSpecificityWeight)); } - private static DateTimeOffset GetStatementTimestamp(NormalizedStatement statement) + private static DateTimeOffset GetStatementTimestamp(NormalizedStatement statement, TimeProvider timeProvider) { // Use LastSeen if available, otherwise FirstSeen, otherwise current time - return statement.LastSeen ?? statement.FirstSeen ?? DateTimeOffset.UtcNow; + return statement.LastSeen ?? statement.FirstSeen ?? timeProvider.GetUtcNow(); } private static bool HasSignature(Trust.TrustWeightResult weight) diff --git a/src/VexLens/StellaOps.VexLens/Export/IConsensusExportService.cs b/src/VexLens/StellaOps.VexLens/Export/IConsensusExportService.cs index 804e36cb2..40f2e0d39 100644 --- a/src/VexLens/StellaOps.VexLens/Export/IConsensusExportService.cs +++ b/src/VexLens/StellaOps.VexLens/Export/IConsensusExportService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using StellaOps.Determinism; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; using StellaOps.VexLens.Storage; @@ -273,12 +274,19 @@ public enum ExportFormat public sealed class ConsensusExportService : IConsensusExportService { private readonly IConsensusProjectionStore _projectionStore; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; private const string SnapshotVersion = "1.0.0"; - public ConsensusExportService(IConsensusProjectionStore projectionStore) + public ConsensusExportService( + IConsensusProjectionStore projectionStore, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _projectionStore = projectionStore; + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? new SystemGuidProvider(); } public async Task CreateSnapshotAsync( @@ -338,12 +346,12 @@ public sealed class ConsensusExportService : IConsensusExportService .GroupBy(p => p.Status) .ToDictionary(g => g.Key, g => g.Count()); - var snapshotId = $"snap-{Guid.NewGuid():N}"; + var snapshotId = $"snap-{_guidProvider.NewGuid():N}"; var contentHash = ComputeContentHash(projections); return new ConsensusSnapshot( SnapshotId: snapshotId, - CreatedAt: DateTimeOffset.UtcNow, + CreatedAt: _timeProvider.GetUtcNow(), Version: SnapshotVersion, TenantId: request.TenantId, Projections: projections, @@ -400,13 +408,13 @@ public sealed class ConsensusExportService : IConsensusExportService // For a true incremental, we'd compare with the previous snapshot // Here we just return new/updated since the timestamp - var snapshotId = $"snap-inc-{Guid.NewGuid():N}"; + var snapshotId = $"snap-inc-{_guidProvider.NewGuid():N}"; var contentHash = ComputeContentHash(current.Projections); return new IncrementalSnapshot( SnapshotId: snapshotId, PreviousSnapshotId: lastSnapshotId, - CreatedAt: DateTimeOffset.UtcNow, + CreatedAt: _timeProvider.GetUtcNow(), Version: SnapshotVersion, Added: current.Projections, Removed: [], // Would need previous snapshot to determine removed diff --git a/src/VexLens/StellaOps.VexLens/Normalization/CsafVexNormalizer.cs b/src/VexLens/StellaOps.VexLens/Normalization/CsafVexNormalizer.cs index dc30d43b4..571f183c8 100644 --- a/src/VexLens/StellaOps.VexLens/Normalization/CsafVexNormalizer.cs +++ b/src/VexLens/StellaOps.VexLens/Normalization/CsafVexNormalizer.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using StellaOps.Determinism; using StellaOps.VexLens.Models; namespace StellaOps.VexLens.Normalization; @@ -12,6 +13,13 @@ namespace StellaOps.VexLens.Normalization; /// public sealed class CsafVexNormalizer : IVexNormalizer { + private readonly IGuidProvider _guidProvider; + + public CsafVexNormalizer(IGuidProvider? guidProvider = null) + { + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; + } + public VexSourceFormat SourceFormat => VexSourceFormat.CsafVex; public bool CanNormalize(string content) @@ -77,7 +85,7 @@ public sealed class CsafVexNormalizer : IVexNormalizer var documentId = ExtractDocumentId(documentElement); if (string.IsNullOrWhiteSpace(documentId)) { - documentId = $"csaf:{Guid.NewGuid():N}"; + documentId = $"csaf:{_guidProvider.NewGuid():N}"; warnings.Add(new NormalizationWarning( "WARN_CSAF_001", "Document tracking ID not found; generated a random ID", diff --git a/src/VexLens/StellaOps.VexLens/Normalization/CycloneDxVexNormalizer.cs b/src/VexLens/StellaOps.VexLens/Normalization/CycloneDxVexNormalizer.cs index 218a4f422..98e36acfb 100644 --- a/src/VexLens/StellaOps.VexLens/Normalization/CycloneDxVexNormalizer.cs +++ b/src/VexLens/StellaOps.VexLens/Normalization/CycloneDxVexNormalizer.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using StellaOps.Determinism; using StellaOps.VexLens.Models; namespace StellaOps.VexLens.Normalization; @@ -12,6 +13,13 @@ namespace StellaOps.VexLens.Normalization; /// public sealed class CycloneDxVexNormalizer : IVexNormalizer { + private readonly IGuidProvider _guidProvider; + + public CycloneDxVexNormalizer(IGuidProvider? guidProvider = null) + { + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; + } + public VexSourceFormat SourceFormat => VexSourceFormat.CycloneDxVex; public bool CanNormalize(string content) @@ -65,7 +73,7 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer var documentId = ExtractDocumentId(root); if (string.IsNullOrWhiteSpace(documentId)) { - documentId = $"cyclonedx:{Guid.NewGuid():N}"; + documentId = $"cyclonedx:{_guidProvider.NewGuid():N}"; warnings.Add(new NormalizationWarning( "WARN_CDX_001", "Serial number not found; generated a random ID", diff --git a/src/VexLens/StellaOps.VexLens/Normalization/OpenVexNormalizer.cs b/src/VexLens/StellaOps.VexLens/Normalization/OpenVexNormalizer.cs index c0b18735d..f9d6c1c5b 100644 --- a/src/VexLens/StellaOps.VexLens/Normalization/OpenVexNormalizer.cs +++ b/src/VexLens/StellaOps.VexLens/Normalization/OpenVexNormalizer.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Security.Cryptography; using System.Text; using System.Text.Json; +using StellaOps.Determinism; using StellaOps.VexLens.Models; namespace StellaOps.VexLens.Normalization; @@ -11,6 +12,13 @@ namespace StellaOps.VexLens.Normalization; /// public sealed class OpenVexNormalizer : IVexNormalizer { + private readonly IGuidProvider _guidProvider; + + public OpenVexNormalizer(IGuidProvider? guidProvider = null) + { + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; + } + public VexSourceFormat SourceFormat => VexSourceFormat.OpenVex; public bool CanNormalize(string content) @@ -58,7 +66,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer var documentId = ExtractDocumentId(root); if (string.IsNullOrWhiteSpace(documentId)) { - documentId = $"openvex:{Guid.NewGuid():N}"; + documentId = $"openvex:{_guidProvider.NewGuid():N}"; warnings.Add(new NormalizationWarning( "WARN_OPENVEX_001", "Document ID not found; generated a random ID", @@ -207,7 +215,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer return null; } - private static IReadOnlyList ExtractStatements( + private IReadOnlyList ExtractStatements( JsonElement root, List warnings, ref int skipped) @@ -227,7 +235,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer foreach (var stmt in statementsArray.EnumerateArray()) { - var statement = ExtractStatement(stmt, index, warnings, ref skipped); + var statement = ExtractStatement(stmt, index, warnings, ref skipped, _guidProvider); if (statement != null) { statements.Add(statement); @@ -243,7 +251,8 @@ public sealed class OpenVexNormalizer : IVexNormalizer JsonElement stmt, int index, List warnings, - ref int skipped) + ref int skipped, + IGuidProvider? guidProvider = null) { // Extract vulnerability string? vulnerabilityId = null; @@ -298,7 +307,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer { foreach (var prod in productsArray.EnumerateArray()) { - var product = ExtractProduct(prod); + var product = ExtractProduct(prod, guidProvider); if (product != null) { products.Add(product); @@ -378,7 +387,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer LastSeen: timestamp); } - private static NormalizedProduct? ExtractProduct(JsonElement prod) + private static NormalizedProduct? ExtractProduct(JsonElement prod, IGuidProvider? guidProvider = null) { string? key = null; string? name = null; @@ -423,8 +432,9 @@ public sealed class OpenVexNormalizer : IVexNormalizer return null; } + var fallbackGuid = guidProvider?.NewGuid() ?? Guid.NewGuid(); return new NormalizedProduct( - Key: key ?? purl ?? cpe ?? $"unknown-{Guid.NewGuid():N}", + Key: key ?? purl ?? cpe ?? $"unknown-{fallbackGuid:N}", Name: name, Version: version, Purl: purl, diff --git a/src/VexLens/StellaOps.VexLens/Orchestration/IConsensusJobService.cs b/src/VexLens/StellaOps.VexLens/Orchestration/IConsensusJobService.cs index 3e23d9a45..27c7e0d8f 100644 --- a/src/VexLens/StellaOps.VexLens/Orchestration/IConsensusJobService.cs +++ b/src/VexLens/StellaOps.VexLens/Orchestration/IConsensusJobService.cs @@ -160,6 +160,7 @@ public sealed class ConsensusJobService : IConsensusJobService private readonly IVexConsensusEngine _consensusEngine; private readonly IConsensusProjectionStore _projectionStore; private readonly IConsensusExportService _exportService; + private readonly TimeProvider _timeProvider; private const string SchemaVersion = "1.0.0"; @@ -172,11 +173,13 @@ public sealed class ConsensusJobService : IConsensusJobService public ConsensusJobService( IVexConsensusEngine consensusEngine, IConsensusProjectionStore projectionStore, - IConsensusExportService exportService) + IConsensusExportService exportService, + TimeProvider? timeProvider = null) { _consensusEngine = consensusEngine; _projectionStore = projectionStore; _exportService = exportService; + _timeProvider = timeProvider ?? TimeProvider.System; } public ConsensusJobRequest CreateComputeJob( @@ -299,7 +302,7 @@ public sealed class ConsensusJobService : IConsensusJobService JobType: ConsensusJobTypes.SnapshotCreate, TenantId: request.TenantId, Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.SnapshotCreate), - IdempotencyKey: $"snapshot:{requestHash}:{DateTimeOffset.UtcNow:yyyyMMddHHmm}", + IdempotencyKey: $"snapshot:{requestHash}:{_timeProvider.GetUtcNow():yyyyMMddHHmm}", Payload: JsonSerializer.Serialize(payload, JsonOptions)); } @@ -307,7 +310,7 @@ public sealed class ConsensusJobService : IConsensusJobService ConsensusJobRequest request, CancellationToken cancellationToken = default) { - var startTime = DateTimeOffset.UtcNow; + var startTime = _timeProvider.GetUtcNow(); try { @@ -350,7 +353,7 @@ public sealed class ConsensusJobService : IConsensusJobService ConsensusJobRequest request, CancellationToken cancellationToken) { - var startTime = DateTimeOffset.UtcNow; + var startTime = _timeProvider.GetUtcNow(); var payload = JsonSerializer.Deserialize(request.Payload, JsonOptions) ?? throw new InvalidOperationException("Invalid compute payload"); @@ -363,7 +366,7 @@ public sealed class ConsensusJobService : IConsensusJobService JobType: request.JobType, ItemsProcessed: 1, ItemsFailed: 0, - Duration: DateTimeOffset.UtcNow - startTime, + Duration: _timeProvider.GetUtcNow() - startTime, ResultPayload: JsonSerializer.Serialize(new { vulnerabilityId = payload.VulnerabilityId, @@ -377,7 +380,7 @@ public sealed class ConsensusJobService : IConsensusJobService ConsensusJobRequest request, CancellationToken cancellationToken) { - var startTime = DateTimeOffset.UtcNow; + var startTime = _timeProvider.GetUtcNow(); var payload = JsonSerializer.Deserialize(request.Payload, JsonOptions) ?? throw new InvalidOperationException("Invalid batch compute payload"); @@ -389,7 +392,7 @@ public sealed class ConsensusJobService : IConsensusJobService JobType: request.JobType, ItemsProcessed: itemCount, ItemsFailed: 0, - Duration: DateTimeOffset.UtcNow - startTime, + Duration: _timeProvider.GetUtcNow() - startTime, ResultPayload: JsonSerializer.Serialize(new { processedCount = itemCount }, JsonOptions), ErrorMessage: null); } @@ -398,7 +401,7 @@ public sealed class ConsensusJobService : IConsensusJobService ConsensusJobRequest request, CancellationToken cancellationToken) { - var startTime = DateTimeOffset.UtcNow; + var startTime = _timeProvider.GetUtcNow(); // Create snapshot using export service var snapshotRequest = ConsensusExportExtensions.FullExportRequest(request.TenantId); @@ -409,7 +412,7 @@ public sealed class ConsensusJobService : IConsensusJobService JobType: request.JobType, ItemsProcessed: snapshot.Projections.Count, ItemsFailed: 0, - Duration: DateTimeOffset.UtcNow - startTime, + Duration: _timeProvider.GetUtcNow() - startTime, ResultPayload: JsonSerializer.Serialize(new { snapshotId = snapshot.SnapshotId, @@ -419,14 +422,14 @@ public sealed class ConsensusJobService : IConsensusJobService ErrorMessage: null); } - private static ConsensusJobResult CreateFailedResult(string jobType, DateTimeOffset startTime, string error) + private ConsensusJobResult CreateFailedResult(string jobType, DateTimeOffset startTime, string error) { return new ConsensusJobResult( Success: false, JobType: jobType, ItemsProcessed: 0, ItemsFailed: 1, - Duration: DateTimeOffset.UtcNow - startTime, + Duration: _timeProvider.GetUtcNow() - startTime, ResultPayload: null, ErrorMessage: error); } diff --git a/src/VexLens/StellaOps.VexLens/Orchestration/OrchestratorLedgerEventEmitter.cs b/src/VexLens/StellaOps.VexLens/Orchestration/OrchestratorLedgerEventEmitter.cs index 835eb17c0..120e33931 100644 --- a/src/VexLens/StellaOps.VexLens/Orchestration/OrchestratorLedgerEventEmitter.cs +++ b/src/VexLens/StellaOps.VexLens/Orchestration/OrchestratorLedgerEventEmitter.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using StellaOps.Determinism; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; using StellaOps.VexLens.Storage; @@ -14,6 +15,8 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter { private readonly IOrchestratorLedgerClient? _ledgerClient; private readonly OrchestratorEventOptions _options; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -23,10 +26,14 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter public OrchestratorLedgerEventEmitter( IOrchestratorLedgerClient? ledgerClient = null, - OrchestratorEventOptions? options = null) + OrchestratorEventOptions? options = null, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _ledgerClient = ledgerClient; _options = options ?? OrchestratorEventOptions.Default; + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? new SystemGuidProvider(); } public async Task EmitConsensusComputedAsync( @@ -144,11 +151,11 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter if (_ledgerClient == null) return; var alertEvent = new LedgerEvent( - EventId: $"alert-{Guid.NewGuid():N}", + EventId: $"alert-{_guidProvider.NewGuid():N}", EventType: ConsensusEventTypes.Alert, TenantId: @event.TenantId, CorrelationId: @event.EventId, - OccurredAt: DateTimeOffset.UtcNow, + OccurredAt: _timeProvider.GetUtcNow(), IdempotencyKey: $"alert-status-{@event.ProjectionId}-{@event.NewStatus}", Actor: new LedgerActor("system", "vexlens", "alert-engine"), Payload: JsonSerializer.Serialize(new @@ -174,11 +181,11 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter if (_ledgerClient == null) return; var alertEvent = new LedgerEvent( - EventId: $"alert-{Guid.NewGuid():N}", + EventId: $"alert-{_guidProvider.NewGuid():N}", EventType: ConsensusEventTypes.Alert, TenantId: @event.TenantId, CorrelationId: @event.EventId, - OccurredAt: DateTimeOffset.UtcNow, + OccurredAt: _timeProvider.GetUtcNow(), IdempotencyKey: $"alert-conflict-{@event.ProjectionId}", Actor: new LedgerActor("system", "vexlens", "alert-engine"), Payload: JsonSerializer.Serialize(new diff --git a/src/VexLens/StellaOps.VexLens/Proof/VexProofBuilder.cs b/src/VexLens/StellaOps.VexLens/Proof/VexProofBuilder.cs index 55580a3f7..ec5b42582 100644 --- a/src/VexLens/StellaOps.VexLens/Proof/VexProofBuilder.cs +++ b/src/VexLens/StellaOps.VexLens/Proof/VexProofBuilder.cs @@ -1,6 +1,7 @@ // Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors. using System.Collections.Immutable; +using StellaOps.Determinism; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; @@ -13,6 +14,7 @@ namespace StellaOps.VexLens.Proof; public sealed class VexProofBuilder { private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; private readonly List _statements = []; private readonly List _mergeSteps = []; private readonly List _conflicts = []; @@ -48,11 +50,12 @@ public sealed class VexProofBuilder private decimal _conditionCoverage = 1.0m; /// - /// Creates a new VexProofBuilder with the specified time provider. + /// Creates a new VexProofBuilder with the specified time provider and GUID provider. /// - public VexProofBuilder(TimeProvider timeProvider) + public VexProofBuilder(TimeProvider timeProvider, IGuidProvider? guidProvider = null) { _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } /// @@ -533,10 +536,10 @@ public sealed class VexProofBuilder _ => ConfidenceTier.VeryLow }; - private static string GenerateProofId(DateTimeOffset timestamp) + private string GenerateProofId(DateTimeOffset timestamp) { var timePart = timestamp.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture); - var randomPart = Guid.NewGuid().ToString("N")[..8]; + var randomPart = _guidProvider.NewGuid().ToString("N")[..8]; return $"proof-{timePart}-{randomPart}"; } } diff --git a/src/VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj b/src/VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj index ad459b71f..79a7fea1d 100644 --- a/src/VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj +++ b/src/VexLens/StellaOps.VexLens/StellaOps.VexLens.csproj @@ -30,6 +30,8 @@ + + diff --git a/src/VexLens/StellaOps.VexLens/Storage/InMemoryConsensusProjectionStore.cs b/src/VexLens/StellaOps.VexLens/Storage/InMemoryConsensusProjectionStore.cs index 2c403c3c5..1cd11a39c 100644 --- a/src/VexLens/StellaOps.VexLens/Storage/InMemoryConsensusProjectionStore.cs +++ b/src/VexLens/StellaOps.VexLens/Storage/InMemoryConsensusProjectionStore.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using StellaOps.Determinism; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; using StellaOps.VexLens.Services; @@ -16,13 +17,19 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore private readonly IConsensusEventEmitter? _eventEmitter; // LIN-BE-009: Delta service for computing VEX deltas on status change private readonly IVexDeltaComputeService? _deltaComputeService; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; public InMemoryConsensusProjectionStore( IConsensusEventEmitter? eventEmitter = null, - IVexDeltaComputeService? deltaComputeService = null) + IVexDeltaComputeService? deltaComputeService = null, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _eventEmitter = eventEmitter; _deltaComputeService = deltaComputeService; + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? SystemGuidProvider.Instance; } public async Task StoreAsync( @@ -31,7 +38,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore CancellationToken cancellationToken = default) { var key = GetKey(result.VulnerabilityId, result.ProductKey, options.TenantId); - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); // Get previous projection for history tracking ConsensusProjection? previous = null; @@ -52,7 +59,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore } var projection = new ConsensusProjection( - ProjectionId: $"proj-{Guid.NewGuid():N}", + ProjectionId: $"proj-{_guidProvider.NewGuid():N}", VulnerabilityId: result.VulnerabilityId, ProductKey: result.ProductKey, TenantId: options.TenantId, @@ -283,12 +290,12 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore { if (_eventEmitter == null) return; - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); // Always emit computed event await _eventEmitter.EmitConsensusComputedAsync( new ConsensusComputedEvent( - EventId: $"evt-{Guid.NewGuid():N}", + EventId: $"evt-{_guidProvider.NewGuid():N}", ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, @@ -307,7 +314,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore { await _eventEmitter.EmitStatusChangedAsync( new ConsensusStatusChangedEvent( - EventId: $"evt-{Guid.NewGuid():N}", + EventId: $"evt-{_guidProvider.NewGuid():N}", ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, @@ -325,7 +332,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore await _deltaComputeService.ComputeAndStoreAsync( new VexStatusChangeContext { - ProjectionId = Guid.TryParse(projection.ProjectionId, out var pid) ? pid : Guid.NewGuid(), + ProjectionId = Guid.TryParse(projection.ProjectionId, out var pid) ? pid : _guidProvider.NewGuid(), VulnerabilityId = projection.VulnerabilityId, ProductKey = projection.ProductKey, ArtifactDigest = projection.ProductKey, // Use ProductKey as artifact identifier @@ -355,7 +362,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore await _eventEmitter.EmitConflictDetectedAsync( new ConsensusConflictDetectedEvent( - EventId: $"evt-{Guid.NewGuid():N}", + EventId: $"evt-{_guidProvider.NewGuid():N}", ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, diff --git a/src/VexLens/StellaOps.VexLens/Storage/PostgresConsensusProjectionStoreProxy.cs b/src/VexLens/StellaOps.VexLens/Storage/PostgresConsensusProjectionStoreProxy.cs index a33e478af..affd6ab63 100644 --- a/src/VexLens/StellaOps.VexLens/Storage/PostgresConsensusProjectionStoreProxy.cs +++ b/src/VexLens/StellaOps.VexLens/Storage/PostgresConsensusProjectionStoreProxy.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; using Npgsql; +using StellaOps.Determinism; using StellaOps.VexLens.Consensus; using StellaOps.VexLens.Models; using StellaOps.VexLens.Options; @@ -28,19 +29,22 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection private readonly ILogger _logger; private readonly VexLensStorageOptions _options; private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; public PostgresConsensusProjectionStoreProxy( NpgsqlDataSource dataSource, ILogger logger, IConsensusEventEmitter? eventEmitter = null, VexLensStorageOptions? options = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _eventEmitter = eventEmitter; _options = options ?? new VexLensStorageOptions(); _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? new SystemGuidProvider(); } private const string Schema = "vexlens"; @@ -108,7 +112,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection activity?.SetTag("vulnerabilityId", result.VulnerabilityId); activity?.SetTag("productKey", result.ProductKey); - var projectionId = Guid.NewGuid(); + var projectionId = _guidProvider.NewGuid(); var now = _timeProvider.GetUtcNow(); // Check for previous projection to track history @@ -517,7 +521,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection var now = _timeProvider.GetUtcNow(); var computedEvent = new ConsensusComputedEvent( - EventId: $"evt-{Guid.NewGuid():N}", + EventId: $"evt-{_guidProvider.NewGuid():N}", ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, @@ -535,7 +539,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection if (projection.StatusChanged && previous is not null) { var changedEvent = new ConsensusStatusChangedEvent( - EventId: $"evt-{Guid.NewGuid():N}", + EventId: $"evt-{_guidProvider.NewGuid():N}", ProjectionId: projection.ProjectionId, VulnerabilityId: projection.VulnerabilityId, ProductKey: projection.ProductKey, diff --git a/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/ISourceTrustScoreCalculator.cs b/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/ISourceTrustScoreCalculator.cs index 288e470b6..e03ac87ab 100644 --- a/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/ISourceTrustScoreCalculator.cs +++ b/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/ISourceTrustScoreCalculator.cs @@ -65,9 +65,9 @@ public sealed record SourceTrustScoreRequest public required SourceVerificationSummary VerificationSummary { get; init; } /// - /// Time at which to evaluate the score. + /// Time at which to evaluate the score. Required for determinism. /// - public DateTimeOffset EvaluationTime { get; init; } = DateTimeOffset.UtcNow; + public required DateTimeOffset EvaluationTime { get; init; } /// /// Previous score for trend calculation. diff --git a/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/InMemorySourceTrustScoreCache.cs b/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/InMemorySourceTrustScoreCache.cs index f1e064f86..50ef0b2f7 100644 --- a/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/InMemorySourceTrustScoreCache.cs +++ b/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/InMemorySourceTrustScoreCache.cs @@ -9,16 +9,18 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache { private readonly ConcurrentDictionary _cache = new(); private readonly Timer _cleanupTimer; + private readonly TimeProvider _timeProvider; - public InMemorySourceTrustScoreCache() + public InMemorySourceTrustScoreCache(TimeProvider? timeProvider = null) { + _timeProvider = timeProvider ?? TimeProvider.System; // Clean up expired entries every 5 minutes _cleanupTimer = new Timer(CleanupExpiredEntries, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5)); } public Task GetAsync(string sourceId, CancellationToken cancellationToken = default) { - if (_cache.TryGetValue(sourceId, out var entry) && entry.ExpiresAt > DateTimeOffset.UtcNow) + if (_cache.TryGetValue(sourceId, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow()) { return Task.FromResult(entry.Score); } @@ -28,7 +30,7 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache public Task SetAsync(string sourceId, VexSourceTrustScore score, TimeSpan duration, CancellationToken cancellationToken = default) { - var entry = new CacheEntry(score, DateTimeOffset.UtcNow + duration); + var entry = new CacheEntry(score, _timeProvider.GetUtcNow() + duration); _cache[sourceId] = entry; return Task.CompletedTask; } @@ -41,7 +43,7 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache private void CleanupExpiredEntries(object? state) { - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var expiredKeys = _cache .Where(kvp => kvp.Value.ExpiresAt <= now) .Select(kvp => kvp.Key) diff --git a/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/ProvenanceChainValidator.cs b/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/ProvenanceChainValidator.cs index a9a1db66b..cd3ded514 100644 --- a/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/ProvenanceChainValidator.cs +++ b/src/VexLens/StellaOps.VexLens/Trust/SourceTrust/ProvenanceChainValidator.cs @@ -11,13 +11,16 @@ public sealed class ProvenanceChainValidator : IProvenanceChainValidator { private readonly ILogger _logger; private readonly IIssuerDirectory _issuerDirectory; + private readonly TimeProvider _timeProvider; public ProvenanceChainValidator( ILogger logger, - IIssuerDirectory issuerDirectory) + IIssuerDirectory issuerDirectory, + TimeProvider? timeProvider = null) { _logger = logger; _issuerDirectory = issuerDirectory; + _timeProvider = timeProvider ?? TimeProvider.System; } public async Task ValidateAsync( @@ -44,7 +47,7 @@ public sealed class ProvenanceChainValidator : IProvenanceChainValidator // Validate chain age if (options.MaxChainAge.HasValue) { - var chainAge = DateTimeOffset.UtcNow - chain.Origin.Timestamp; + var chainAge = _timeProvider.GetUtcNow() - chain.Origin.Timestamp; if (chainAge > options.MaxChainAge.Value) { issues.Add(new ProvenanceIssue diff --git a/src/VexLens/StellaOps.VexLens/Verification/InMemoryIssuerDirectory.cs b/src/VexLens/StellaOps.VexLens/Verification/InMemoryIssuerDirectory.cs index 4d62f277d..429d60a70 100644 --- a/src/VexLens/StellaOps.VexLens/Verification/InMemoryIssuerDirectory.cs +++ b/src/VexLens/StellaOps.VexLens/Verification/InMemoryIssuerDirectory.cs @@ -11,6 +11,12 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory { private readonly ConcurrentDictionary _issuers = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _fingerprintToIssuer = new(StringComparer.OrdinalIgnoreCase); + private readonly TimeProvider _timeProvider; + + public InMemoryIssuerDirectory(TimeProvider? timeProvider = null) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } public Task GetIssuerAsync( string issuerId, @@ -86,7 +92,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory IssuerRegistration registration, CancellationToken cancellationToken = default) { - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var keyRecords = new List(); if (registration.InitialKeys != null) @@ -135,7 +141,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory return Task.FromResult(false); } - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var updated = current with { Status = IssuerStatus.Revoked, @@ -165,7 +171,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory throw new InvalidOperationException($"Issuer '{issuerId}' not found"); } - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var newKey = new KeyFingerprintRecord( Fingerprint: keyRegistration.Fingerprint, KeyType: keyRegistration.KeyType, @@ -209,7 +215,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory return Task.FromResult(false); } - var now = DateTimeOffset.UtcNow; + var now = _timeProvider.GetUtcNow(); var revokedKey = keyIndex.k with { Status = KeyFingerprintStatus.Revoked, @@ -284,7 +290,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory keyStatus = KeyTrustStatus.Revoked; warnings.Add($"Key was revoked: {key.RevocationReason}"); } - else if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < DateTimeOffset.UtcNow) + else if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < _timeProvider.GetUtcNow()) { keyStatus = KeyTrustStatus.Expired; warnings.Add($"Key expired on {key.ExpiresAt.Value:O}"); diff --git a/src/Zastava/StellaOps.Zastava.Agent/Backend/RuntimeEventsClient.cs b/src/Zastava/StellaOps.Zastava.Agent/Backend/RuntimeEventsClient.cs index 289cd09a7..8b2c0a391 100644 --- a/src/Zastava/StellaOps.Zastava.Agent/Backend/RuntimeEventsClient.cs +++ b/src/Zastava/StellaOps.Zastava.Agent/Backend/RuntimeEventsClient.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Determinism; using StellaOps.Zastava.Agent.Configuration; using StellaOps.Zastava.Core.Contracts; @@ -34,15 +35,18 @@ internal sealed class RuntimeEventsClient : IRuntimeEventsClient private readonly HttpClient _httpClient; private readonly IOptionsMonitor _options; private readonly ILogger _logger; + private readonly IGuidProvider _guidProvider; public RuntimeEventsClient( HttpClient httpClient, IOptionsMonitor options, - ILogger logger) + ILogger logger, + IGuidProvider? guidProvider = null) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _guidProvider = guidProvider ?? new SystemGuidProvider(); } public async Task SubmitAsync( @@ -63,7 +67,7 @@ internal sealed class RuntimeEventsClient : IRuntimeEventsClient { var request = new RuntimeEventsSubmitRequest { - BatchId = Guid.NewGuid().ToString("N"), + BatchId = _guidProvider.NewGuid().ToString("N"), Events = envelopes.ToArray() }; diff --git a/src/Zastava/StellaOps.Zastava.Agent/StellaOps.Zastava.Agent.csproj b/src/Zastava/StellaOps.Zastava.Agent/StellaOps.Zastava.Agent.csproj index eb52eb4da..ec266735f 100644 --- a/src/Zastava/StellaOps.Zastava.Agent/StellaOps.Zastava.Agent.csproj +++ b/src/Zastava/StellaOps.Zastava.Agent/StellaOps.Zastava.Agent.csproj @@ -21,5 +21,6 @@ + diff --git a/src/Zastava/StellaOps.Zastava.Agent/Worker/HealthCheckHostedService.cs b/src/Zastava/StellaOps.Zastava.Agent/Worker/HealthCheckHostedService.cs index b6aca188d..b3c55a30c 100644 --- a/src/Zastava/StellaOps.Zastava.Agent/Worker/HealthCheckHostedService.cs +++ b/src/Zastava/StellaOps.Zastava.Agent/Worker/HealthCheckHostedService.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Determinism; using StellaOps.Zastava.Agent.Configuration; using StellaOps.Zastava.Agent.Docker; @@ -22,16 +23,22 @@ internal sealed class HealthCheckHostedService : BackgroundService private readonly IDockerSocketClient _dockerClient; private readonly IOptionsMonitor _options; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; private HttpListener? _listener; public HealthCheckHostedService( IDockerSocketClient dockerClient, IOptionsMonitor options, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null, + IGuidProvider? guidProvider = null) { _dockerClient = dockerClient ?? throw new ArgumentNullException(nameof(dockerClient)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; + _guidProvider = guidProvider ?? new SystemGuidProvider(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -184,7 +191,7 @@ internal sealed class HealthCheckHostedService : BackgroundService { Status = overallHealthy ? "healthy" : "unhealthy", Checks = checks, - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }; return (overallHealthy ? 200 : 503, response); @@ -203,7 +210,7 @@ internal sealed class HealthCheckHostedService : BackgroundService { Status = "ready", Message = "Agent ready to process container events", - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }); } @@ -211,7 +218,7 @@ internal sealed class HealthCheckHostedService : BackgroundService { Status = "not_ready", Message = "Docker daemon not reachable", - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }); } catch (Exception ex) @@ -220,16 +227,16 @@ internal sealed class HealthCheckHostedService : BackgroundService { Status = "not_ready", Message = $"Ready check failed: {ex.Message}", - Timestamp = DateTimeOffset.UtcNow + Timestamp = _timeProvider.GetUtcNow() }); } } - private static bool IsDirectoryWritable(string path) + private bool IsDirectoryWritable(string path) { try { - var testFile = Path.Combine(path, $".healthcheck-{Guid.NewGuid():N}"); + var testFile = Path.Combine(path, $".healthcheck-{_guidProvider.NewGuid():N}"); File.WriteAllText(testFile, "test"); File.Delete(testFile); return true; diff --git a/src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventBuffer.cs b/src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventBuffer.cs index e42ce3164..10f5c1f3b 100644 --- a/src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventBuffer.cs +++ b/src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventBuffer.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Determinism; using StellaOps.Zastava.Agent.Configuration; using StellaOps.Zastava.Core.Contracts; using StellaOps.Zastava.Core.Serialization; @@ -31,6 +32,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer private readonly string _spoolPath; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; + private readonly IGuidProvider _guidProvider; private readonly long _maxDiskBytes; private readonly int _capacity; @@ -39,11 +41,13 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer public RuntimeEventBuffer( IOptions agentOptions, TimeProvider timeProvider, - ILogger logger) + ILogger logger, + IGuidProvider? guidProvider = null) { ArgumentNullException.ThrowIfNull(agentOptions); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _guidProvider = guidProvider ?? new SystemGuidProvider(); var options = agentOptions.Value ?? throw new ArgumentNullException(nameof(agentOptions)); @@ -178,7 +182,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer private async Task PersistAsync(byte[] payload, CancellationToken cancellationToken) { var timestamp = _timeProvider.GetUtcNow().UtcTicks; - var fileName = $"{timestamp:D20}-{Guid.NewGuid():N}{FileExtension}"; + var fileName = $"{timestamp:D20}-{_guidProvider.NewGuid():N}{FileExtension}"; var filePath = Path.Combine(_spoolPath, fileName); Directory.CreateDirectory(_spoolPath); diff --git a/src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventDispatchService.cs b/src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventDispatchService.cs index f25708aac..92583ed65 100644 --- a/src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventDispatchService.cs +++ b/src/Zastava/StellaOps.Zastava.Agent/Worker/RuntimeEventDispatchService.cs @@ -16,18 +16,21 @@ internal sealed class RuntimeEventDispatchService : BackgroundService private readonly IRuntimeEventsClient _eventsClient; private readonly IOptionsMonitor _options; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private readonly Random _jitterRandom = new(); public RuntimeEventDispatchService( IRuntimeEventBuffer eventBuffer, IRuntimeEventsClient eventsClient, IOptionsMonitor options, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _eventBuffer = eventBuffer ?? throw new ArgumentNullException(nameof(eventBuffer)); _eventsClient = eventsClient ?? throw new ArgumentNullException(nameof(eventsClient)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -43,7 +46,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService flushInterval); var batch = new List(batchSize); - var lastFlush = DateTimeOffset.UtcNow; + var lastFlush = _timeProvider.GetUtcNow(); var failureCount = 0; try @@ -53,7 +56,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService batch.Add(item); var shouldFlush = batch.Count >= batchSize || - (batch.Count > 0 && DateTimeOffset.UtcNow - lastFlush >= flushInterval); + (batch.Count > 0 && _timeProvider.GetUtcNow() - lastFlush >= flushInterval); if (shouldFlush) { @@ -68,7 +71,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService } batch.Clear(); - lastFlush = DateTimeOffset.UtcNow; + lastFlush = _timeProvider.GetUtcNow(); } } } diff --git a/src/Zastava/StellaOps.Zastava.Observer/Probes/EbpfProbeManager.cs b/src/Zastava/StellaOps.Zastava.Observer/Probes/EbpfProbeManager.cs index 91f1d2904..2a3695373 100644 --- a/src/Zastava/StellaOps.Zastava.Observer/Probes/EbpfProbeManager.cs +++ b/src/Zastava/StellaOps.Zastava.Observer/Probes/EbpfProbeManager.cs @@ -23,6 +23,7 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable private readonly IRuntimeSignalCollector _signalCollector; private readonly ISignalPublisher _signalPublisher; private readonly EbpfProbeManagerOptions _options; + private readonly TimeProvider _timeProvider; private readonly ConcurrentDictionary _activeHandles; private bool _disposed; @@ -30,12 +31,14 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable ILogger logger, IRuntimeSignalCollector signalCollector, ISignalPublisher signalPublisher, - IOptions options) + IOptions options, + TimeProvider? timeProvider = null) { _logger = logger; _signalCollector = signalCollector; _signalPublisher = signalPublisher; _options = options.Value; + _timeProvider = timeProvider ?? TimeProvider.System; _activeHandles = new ConcurrentDictionary(); } @@ -277,7 +280,7 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable Namespace = evt.Labels.GetValueOrDefault("io.kubernetes.pod.namespace"), PodName = evt.Labels.GetValueOrDefault("io.kubernetes.pod.name"), Summary = summary, - CollectedAt = DateTimeOffset.UtcNow, + CollectedAt = _timeProvider.GetUtcNow(), }; await _signalPublisher.PublishAsync(message, ct); diff --git a/src/Zastava/StellaOps.Zastava.Observer/Runtime/ProcSnapshot/ProcSnapshotCollector.cs b/src/Zastava/StellaOps.Zastava.Observer/Runtime/ProcSnapshot/ProcSnapshotCollector.cs index 7e357c02e..e48be4ba3 100644 --- a/src/Zastava/StellaOps.Zastava.Observer/Runtime/ProcSnapshot/ProcSnapshotCollector.cs +++ b/src/Zastava/StellaOps.Zastava.Observer/Runtime/ProcSnapshot/ProcSnapshotCollector.cs @@ -30,10 +30,12 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector private readonly DotNetAssemblyCollector _dotnetCollector; private readonly PhpAutoloadCollector _phpCollector; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private readonly string _procRoot; public ProcSnapshotCollector( IOptions options, + TimeProvider? timeProvider, ILoggerFactory loggerFactory) { ArgumentNullException.ThrowIfNull(options); @@ -41,6 +43,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector _procRoot = options.Value.ProcRootPath; _logger = loggerFactory.CreateLogger(); + _timeProvider = timeProvider ?? TimeProvider.System; _javaCollector = new JavaClasspathCollector( _procRoot, @@ -82,7 +85,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector var document = new ProcSnapshotDocument { - Id = $"{tenant}:{imageDigest}:{pid}:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}", + Id = $"{tenant}:{imageDigest}:{pid}:{_timeProvider.GetUtcNow().ToUnixTimeMilliseconds()}", Tenant = tenant, ImageDigest = imageDigest, ContainerId = container.Id, @@ -91,7 +94,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector Classpath = snapshot.Classpath, LoadedAssemblies = snapshot.LoadedAssemblies, AutoloadPaths = snapshot.AutoloadPaths, - CapturedAt = DateTimeOffset.UtcNow + CapturedAt = _timeProvider.GetUtcNow() }; _logger.LogDebug( diff --git a/src/Zastava/StellaOps.Zastava.Observer/Runtime/RuntimeEventBuffer.cs b/src/Zastava/StellaOps.Zastava.Observer/Runtime/RuntimeEventBuffer.cs index d53897d59..c26e416c8 100644 --- a/src/Zastava/StellaOps.Zastava.Observer/Runtime/RuntimeEventBuffer.cs +++ b/src/Zastava/StellaOps.Zastava.Observer/Runtime/RuntimeEventBuffer.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Threading.Channels; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Determinism; using StellaOps.Zastava.Core.Contracts; using StellaOps.Zastava.Core.Serialization; using StellaOps.Zastava.Observer.Configuration; @@ -32,6 +33,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer private readonly string spoolPath; private readonly ILogger logger; private readonly TimeProvider timeProvider; + private readonly IGuidProvider guidProvider; private readonly long maxDiskBytes; private long currentBytes; @@ -40,11 +42,13 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer public RuntimeEventBuffer( IOptions observerOptions, TimeProvider timeProvider, - ILogger logger) + ILogger logger, + IGuidProvider? guidProvider = null) { ArgumentNullException.ThrowIfNull(observerOptions); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.guidProvider = guidProvider ?? SystemGuidProvider.Instance; var options = observerOptions.Value ?? throw new ArgumentNullException(nameof(observerOptions)); @@ -178,7 +182,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer private async Task PersistAsync(byte[] payload, CancellationToken cancellationToken) { var timestamp = timeProvider.GetUtcNow().UtcTicks; - var fileName = $"{timestamp:D20}-{Guid.NewGuid():N}{FileExtension}"; + var fileName = $"{timestamp:D20}-{guidProvider.NewGuid():N}{FileExtension}"; var filePath = Path.Combine(spoolPath, fileName); Directory.CreateDirectory(spoolPath); diff --git a/src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj b/src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj index 30704ba56..551afacc0 100644 --- a/src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj +++ b/src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Zastava/StellaOps.Zastava.Observer/Worker/RuntimeEventDispatchService.cs b/src/Zastava/StellaOps.Zastava.Observer/Worker/RuntimeEventDispatchService.cs index 84e0751bd..0ef173847 100644 --- a/src/Zastava/StellaOps.Zastava.Observer/Worker/RuntimeEventDispatchService.cs +++ b/src/Zastava/StellaOps.Zastava.Observer/Worker/RuntimeEventDispatchService.cs @@ -3,6 +3,7 @@ using System.Net; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using StellaOps.Determinism; using StellaOps.Zastava.Core.Contracts; using StellaOps.Zastava.Observer.Backend; using StellaOps.Zastava.Observer.Configuration; @@ -17,6 +18,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService private readonly IRuntimeFactsClient runtimeFactsClient; private readonly IOptionsMonitor observerOptions; private readonly TimeProvider timeProvider; + private readonly IGuidProvider guidProvider; private readonly ILogger logger; public RuntimeEventDispatchService( @@ -25,6 +27,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService IRuntimeFactsClient runtimeFactsClient, IOptionsMonitor observerOptions, TimeProvider timeProvider, + IGuidProvider? guidProvider, ILogger logger) { this.buffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); @@ -32,6 +35,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService this.runtimeFactsClient = runtimeFactsClient ?? throw new ArgumentNullException(nameof(runtimeFactsClient)); this.observerOptions = observerOptions ?? throw new ArgumentNullException(nameof(observerOptions)); this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + this.guidProvider = guidProvider ?? SystemGuidProvider.Instance; this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -127,7 +131,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService var request = new RuntimeEventsIngestRequest { - BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{Guid.NewGuid():N}", + BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{guidProvider.NewGuid():N}", Events = envelopes }; diff --git a/src/Zastava/StellaOps.Zastava.Webhook/Certificates/WebhookCertificateHealthCheck.cs b/src/Zastava/StellaOps.Zastava.Webhook/Certificates/WebhookCertificateHealthCheck.cs index 74dc92f80..1a431faa6 100644 --- a/src/Zastava/StellaOps.Zastava.Webhook/Certificates/WebhookCertificateHealthCheck.cs +++ b/src/Zastava/StellaOps.Zastava.Webhook/Certificates/WebhookCertificateHealthCheck.cs @@ -6,14 +6,17 @@ public sealed class WebhookCertificateHealthCheck : IHealthCheck { private readonly IWebhookCertificateProvider _certificateProvider; private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; private readonly TimeSpan _expiryThreshold = TimeSpan.FromDays(7); public WebhookCertificateHealthCheck( IWebhookCertificateProvider certificateProvider, - ILogger logger) + ILogger logger, + TimeProvider? timeProvider = null) { _certificateProvider = certificateProvider; _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; } public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) @@ -22,7 +25,7 @@ public sealed class WebhookCertificateHealthCheck : IHealthCheck { var certificate = _certificateProvider.GetCertificate(); var expires = certificate.NotAfter.ToUniversalTime(); - var remaining = expires - DateTimeOffset.UtcNow; + var remaining = expires - _timeProvider.GetUtcNow(); if (remaining <= TimeSpan.Zero) {