save progress
This commit is contained in:
503
.gitea/workflows/secrets-bundle-release.yml
Normal file
503
.gitea/workflows/secrets-bundle-release.yml
Normal file
@@ -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] <github-actions[bot]@users.noreply.github.com>"
|
||||
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
231
devops/offline/scripts/install-secrets-bundle.sh
Normal file
231
devops/offline/scripts/install-secrets-bundle.sh
Normal file
@@ -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 <bundle-path> [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}" <<EOF
|
||||
{
|
||||
"bundleId": "${BUNDLE_ID}",
|
||||
"version": "${BUNDLE_VERSION}",
|
||||
"ruleCount": ${RULE_COUNT},
|
||||
"signerKeyId": "${SIGNER_KEY_ID}",
|
||||
"installedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"installedFrom": "${BUNDLE_PATH}",
|
||||
"installedBy": "${USER:-unknown}",
|
||||
"hostname": "$(hostname -f 2>/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"
|
||||
299
devops/offline/scripts/rotate-secrets-bundle.sh
Normal file
299
devops/offline/scripts/rotate-secrets-bundle.sh
Normal file
@@ -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 <new-bundle-path> [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" <<EOF
|
||||
{
|
||||
"version": "${CURRENT_VERSION}",
|
||||
"backupAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"reason": "rotation-to-${NEW_VERSION}",
|
||||
"hostname": "$(hostname -f 2>/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}" <<EOF
|
||||
{
|
||||
"previousVersion": "${CURRENT_VERSION}",
|
||||
"newVersion": "${NEW_VERSION}",
|
||||
"previousRuleCount": ${CURRENT_RULE_COUNT},
|
||||
"newRuleCount": ${NEW_RULE_COUNT},
|
||||
"rotatedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"backupPath": "${BACKUP_DIR:-null}",
|
||||
"hostname": "$(hostname -f 2>/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 "$@"
|
||||
@@ -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/<aa>/<rest>.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/<version>/` 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/<version>/
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<IGuidProvider, SystemGuidProvider>();
|
||||
| 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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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<string> EnabledRuleCategories { get; init; }
|
||||
public required IReadOnlyList<SecretExceptionPattern> 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
|
||||
{
|
||||
/// <summary>
|
||||
/// Show only that a secret was detected, no value shown.
|
||||
/// Example: [SECRET_DETECTED: aws_access_key_id]
|
||||
/// </summary>
|
||||
FullMask = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Show first and last 4 characters.
|
||||
/// Example: AKIA****WXYZ
|
||||
/// </summary>
|
||||
PartialReveal = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Show full value (requires elevated permissions).
|
||||
/// Use only for debugging/incident response.
|
||||
/// </summary>
|
||||
FullReveal = 2
|
||||
}
|
||||
|
||||
public sealed record RevelationPolicyConfig
|
||||
{
|
||||
/// <summary>Default policy for UI/API responses.</summary>
|
||||
public SecretRevelationPolicy DefaultPolicy { get; init; } = SecretRevelationPolicy.PartialReveal;
|
||||
|
||||
/// <summary>Policy for exported reports (PDF, JSON).</summary>
|
||||
public SecretRevelationPolicy ExportPolicy { get; init; } = SecretRevelationPolicy.FullMask;
|
||||
|
||||
/// <summary>Policy for logs and telemetry.</summary>
|
||||
public SecretRevelationPolicy LogPolicy { get; init; } = SecretRevelationPolicy.FullMask;
|
||||
|
||||
/// <summary>Roles allowed to use FullReveal.</summary>
|
||||
public IReadOnlyList<string> FullRevealRoles { get; init; } = ["security-admin", "incident-responder"];
|
||||
|
||||
/// <summary>Number of characters to show at start/end for PartialReveal.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>Regex pattern to match against detected secret value.</summary>
|
||||
public required string Pattern { get; init; }
|
||||
|
||||
/// <summary>Optional: Only apply to specific rule IDs.</summary>
|
||||
public IReadOnlyList<string>? ApplicableRuleIds { get; init; }
|
||||
|
||||
/// <summary>Optional: Only apply to specific file paths.</summary>
|
||||
public string? FilePathGlob { get; init; }
|
||||
|
||||
/// <summary>Reason for exception (audit trail).</summary>
|
||||
public required string Justification { get; init; }
|
||||
|
||||
/// <summary>Expiration date (null = permanent).</summary>
|
||||
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 |
|
||||
|
||||
290
docs/implplan/SPRINT_20260104_007_BE_secret_detection_alerts.md
Normal file
290
docs/implplan/SPRINT_20260104_007_BE_secret_detection_alerts.md
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>Enable/disable alerting for this tenant.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>Minimum severity to trigger alert (Critical, High, Medium, Low).</summary>
|
||||
public SecretSeverity MinimumAlertSeverity { get; init; } = SecretSeverity.High;
|
||||
|
||||
/// <summary>Alert destinations by channel type.</summary>
|
||||
public IReadOnlyList<SecretAlertDestination> Destinations { get; init; } = [];
|
||||
|
||||
/// <summary>Rate limit: max alerts per scan.</summary>
|
||||
public int MaxAlertsPerScan { get; init; } = 10;
|
||||
|
||||
/// <summary>Deduplication window: don't re-alert same secret within this period.</summary>
|
||||
public TimeSpan DeduplicationWindow { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>Include file path in alert (may reveal repo structure).</summary>
|
||||
public bool IncludeFilePath { get; init; } = true;
|
||||
|
||||
/// <summary>Include masked secret value in alert.</summary>
|
||||
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<SecretSeverity>? SeverityFilter { get; init; }
|
||||
public IReadOnlyList<string>? 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; }
|
||||
|
||||
/// <summary>Deduplication key for rate limiting.</summary>
|
||||
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<bool> 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<bool> 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 |
|
||||
|
||||
499
docs/implplan/SPRINT_20260104_008_FE_secret_detection_ui.md
Normal file
499
docs/implplan/SPRINT_20260104_008_FE_secret_detection_ui.md
Normal file
@@ -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: `
|
||||
<div class="settings-container">
|
||||
<header class="settings-header">
|
||||
<h1>Secret Detection</h1>
|
||||
<mat-slide-toggle
|
||||
[checked]="settings()?.enabled"
|
||||
(change)="onEnabledChange($event)"
|
||||
color="primary">
|
||||
{{ settings()?.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</mat-slide-toggle>
|
||||
</header>
|
||||
|
||||
<mat-tab-group>
|
||||
<mat-tab label="General">
|
||||
<app-revelation-policy-config
|
||||
[policy]="settings()?.revelationPolicy"
|
||||
(policyChange)="onPolicyChange($event)" />
|
||||
|
||||
<app-rule-category-selector
|
||||
[categories]="availableCategories()"
|
||||
[selected]="settings()?.enabledRuleCategories"
|
||||
(selectionChange)="onCategoriesChange($event)" />
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Exceptions">
|
||||
<app-exception-manager
|
||||
[exceptions]="settings()?.exceptions"
|
||||
(add)="onAddException($event)"
|
||||
(remove)="onRemoveException($event)" />
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Alerts">
|
||||
<app-alert-destination-config
|
||||
[settings]="settings()?.alertSettings"
|
||||
(settingsChange)="onAlertSettingsChange($event)" />
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
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: `
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Secret Revelation Policy</mat-card-title>
|
||||
<mat-card-subtitle>
|
||||
Control how detected secrets are displayed
|
||||
</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<mat-radio-group
|
||||
[value]="policy()?.defaultPolicy"
|
||||
(change)="onDefaultPolicyChange($event)">
|
||||
|
||||
<mat-radio-button value="FullMask">
|
||||
<div class="policy-option">
|
||||
<strong>Full Mask</strong>
|
||||
<span class="example">[REDACTED]</span>
|
||||
<p>No secret value shown. Safest option.</p>
|
||||
</div>
|
||||
</mat-radio-button>
|
||||
|
||||
<mat-radio-button value="PartialReveal">
|
||||
<div class="policy-option">
|
||||
<strong>Partial Reveal</strong>
|
||||
<span class="example">AKIA****WXYZ</span>
|
||||
<p>Show first/last 4 characters. Helps identify specific secrets.</p>
|
||||
</div>
|
||||
</mat-radio-button>
|
||||
|
||||
<mat-radio-button value="FullReveal" [disabled]="!canFullReveal()">
|
||||
<div class="policy-option">
|
||||
<strong>Full Reveal</strong>
|
||||
<span class="example">AKIAIOSFODNN7EXAMPLE</span>
|
||||
<p>Show complete value. Requires security-admin role.</p>
|
||||
</div>
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
|
||||
<mat-divider />
|
||||
|
||||
<h4>Context-Specific Policies</h4>
|
||||
<div class="context-policies">
|
||||
<mat-form-field>
|
||||
<mat-label>Export Reports</mat-label>
|
||||
<mat-select [value]="policy()?.exportPolicy">
|
||||
<mat-option value="FullMask">Full Mask</mat-option>
|
||||
<mat-option value="PartialReveal">Partial Reveal</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Logs & Telemetry</mat-label>
|
||||
<mat-select [value]="policy()?.logPolicy" disabled>
|
||||
<mat-option value="FullMask">Full Mask (Enforced)</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Secrets are never logged in full</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`
|
||||
})
|
||||
```
|
||||
|
||||
### SDU-005: Findings List Component
|
||||
|
||||
```typescript
|
||||
// secret-findings-list.component.ts
|
||||
@Component({
|
||||
selector: 'app-secret-findings-list',
|
||||
template: `
|
||||
<div class="findings-container">
|
||||
<header class="findings-header">
|
||||
<h2>Secret Findings</h2>
|
||||
<div class="filters">
|
||||
<mat-form-field>
|
||||
<mat-label>Severity</mat-label>
|
||||
<mat-select multiple [(value)]="severityFilter">
|
||||
<mat-option value="Critical">Critical</mat-option>
|
||||
<mat-option value="High">High</mat-option>
|
||||
<mat-option value="Medium">Medium</mat-option>
|
||||
<mat-option value="Low">Low</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [(value)]="statusFilter">
|
||||
<mat-option value="Open">Open</mat-option>
|
||||
<mat-option value="Dismissed">Dismissed</mat-option>
|
||||
<mat-option value="Excepted">Excepted</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<table mat-table [dataSource]="findings()">
|
||||
<ng-container matColumnDef="severity">
|
||||
<th mat-header-cell *matHeaderCellDef>Severity</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<app-severity-badge [severity]="finding.severity" />
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="rule">
|
||||
<th mat-header-cell *matHeaderCellDef>Rule</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<div class="rule-info">
|
||||
<span class="rule-name">{{ finding.ruleName }}</span>
|
||||
<span class="rule-category">{{ finding.ruleCategory }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="location">
|
||||
<th mat-header-cell *matHeaderCellDef>Location</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<code class="file-path">{{ finding.filePath }}:{{ finding.lineNumber }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
<th mat-header-cell *matHeaderCellDef>Detected Value</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<app-masked-secret-value
|
||||
[value]="finding.value"
|
||||
[policy]="revelationPolicy()"
|
||||
[canReveal]="canRevealSecrets()" />
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<button mat-icon-button [matMenuTriggerFor]="actionMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #actionMenu>
|
||||
<button mat-menu-item (click)="viewDetails(finding)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View Details
|
||||
</button>
|
||||
<button mat-menu-item (click)="dismiss(finding)">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
Dismiss
|
||||
</button>
|
||||
<button mat-menu-item (click)="addException(finding)">
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
Add Exception
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"
|
||||
(click)="viewDetails(row)"></tr>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
```
|
||||
|
||||
### SDU-006: Masked Value Display
|
||||
|
||||
```typescript
|
||||
// masked-secret-value.component.ts
|
||||
@Component({
|
||||
selector: 'app-masked-secret-value',
|
||||
template: `
|
||||
<div class="masked-value" [class.revealed]="isRevealed()">
|
||||
<code>{{ displayValue() }}</code>
|
||||
|
||||
@if (canReveal() && !isRevealed()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Reveal value (logged)"
|
||||
(click)="reveal()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (isRevealed()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Hide value"
|
||||
(click)="hide()">
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
matTooltip="Copy to clipboard"
|
||||
(click)="copy()">
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
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<string>();
|
||||
policy = input.required<SecretRevelationPolicy>();
|
||||
canReveal = input<boolean>(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: `
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Alert Destinations</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<div class="alert-settings">
|
||||
<mat-slide-toggle [(ngModel)]="settings().enabled">
|
||||
Enable Alerts
|
||||
</mat-slide-toggle>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Minimum Severity</mat-label>
|
||||
<mat-select [(value)]="settings().minimumAlertSeverity">
|
||||
<mat-option value="Critical">Critical only</mat-option>
|
||||
<mat-option value="High">High and above</mat-option>
|
||||
<mat-option value="Medium">Medium and above</mat-option>
|
||||
<mat-option value="Low">All findings</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-divider />
|
||||
|
||||
<h4>Configured Channels</h4>
|
||||
<div class="destinations-list">
|
||||
@for (dest of settings().destinations; track dest.id) {
|
||||
<mat-card class="destination-card">
|
||||
<div class="destination-info">
|
||||
<mat-icon>{{ getChannelIcon(dest.channelType) }}</mat-icon>
|
||||
<span>{{ dest.channelType }}</span>
|
||||
<code>{{ dest.channelId }}</code>
|
||||
</div>
|
||||
<div class="destination-actions">
|
||||
<button mat-icon-button (click)="testChannel(dest)">
|
||||
<mat-icon>send</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn" (click)="removeDestination(dest)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button mat-stroked-button (click)="addDestination()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Destination
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
`
|
||||
})
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -41,7 +41,7 @@ public static class JsonNormalizer
|
||||
}
|
||||
|
||||
var normalized = NormalizeNode(node, options);
|
||||
return normalized.ToJsonString(SerializerOptions);
|
||||
return normalized?.ToJsonString(SerializerOptions) ?? "null";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -128,7 +128,8 @@ public sealed class SbomNormalizer
|
||||
/// </summary>
|
||||
private JsonNode NormalizeGeneric(JsonNode node)
|
||||
{
|
||||
return NormalizeNode(node);
|
||||
// NormalizeNode only returns null if input is null; node is non-null here
|
||||
return NormalizeNode(node)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Validates rule bundles (secrets, malware, etc.) for offline import.
|
||||
/// Verifies signature, version monotonicity, and file digests.
|
||||
/// </summary>
|
||||
public sealed class RuleBundleValidator
|
||||
{
|
||||
private readonly DsseVerifier _dsseVerifier;
|
||||
private readonly IVersionMonotonicityChecker _monotonicityChecker;
|
||||
private readonly ILogger<RuleBundleValidator> _logger;
|
||||
|
||||
public RuleBundleValidator(
|
||||
DsseVerifier dsseVerifier,
|
||||
IVersionMonotonicityChecker monotonicityChecker,
|
||||
ILogger<RuleBundleValidator> logger)
|
||||
{
|
||||
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
|
||||
_monotonicityChecker = monotonicityChecker ?? throw new ArgumentNullException(nameof(monotonicityChecker));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a rule bundle for import.
|
||||
/// </summary>
|
||||
public async Task<RuleBundleValidationResult> 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<string>(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<RuleBundleManifest>(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<string>();
|
||||
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<string> 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<RuleBundleFileEntry> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for validating a rule bundle.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Result of rule bundle validation.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleValidationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> VerificationLog { get; init; } = [];
|
||||
public int RuleCount { get; init; }
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
public static RuleBundleValidationResult Success(
|
||||
string reason,
|
||||
IReadOnlyList<string> verificationLog,
|
||||
int ruleCount,
|
||||
string? signerKeyId) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Reason = reason,
|
||||
VerificationLog = verificationLog,
|
||||
RuleCount = ruleCount,
|
||||
SignerKeyId = signerKeyId
|
||||
};
|
||||
|
||||
public static RuleBundleValidationResult Failure(
|
||||
string reason,
|
||||
IReadOnlyList<string> verificationLog) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Reason = reason,
|
||||
VerificationLog = verificationLog
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest for a rule bundle.
|
||||
/// </summary>
|
||||
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<RuleBundleFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File entry in a rule bundle manifest.
|
||||
/// </summary>
|
||||
internal sealed class RuleBundleFileEntry
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Digest { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public sealed record BundleManifest
|
||||
public ImmutableArray<CatalogComponent> Catalogs { get; init; } = [];
|
||||
public RekorSnapshot? RekorSnapshot { get; init; }
|
||||
public ImmutableArray<CryptoProviderComponent> CryptoProviders { get; init; } = [];
|
||||
public ImmutableArray<RuleBundleComponent> 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<string> SupportedAlgorithms);
|
||||
|
||||
/// <summary>
|
||||
/// Component for a rule bundle (e.g., secrets detection rules).
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Bundle identifier (e.g., "secrets.ruleset").</param>
|
||||
/// <param name="BundleType">Bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="Version">Bundle version in YYYY.MM format.</param>
|
||||
/// <param name="RelativePath">Relative path to the bundle directory.</param>
|
||||
/// <param name="Digest">Combined digest of all files in the bundle.</param>
|
||||
/// <param name="SizeBytes">Total size of the bundle in bytes.</param>
|
||||
/// <param name="RuleCount">Number of rules in the bundle.</param>
|
||||
/// <param name="SignerKeyId">Key ID used to sign the bundle.</param>
|
||||
/// <param name="SignedAt">When the bundle was signed.</param>
|
||||
/// <param name="Files">List of files in the bundle.</param>
|
||||
public sealed record RuleBundleComponent(
|
||||
string BundleId,
|
||||
string BundleType,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
int RuleCount,
|
||||
string? SignerKeyId,
|
||||
DateTimeOffset? SignedAt,
|
||||
ImmutableArray<RuleBundleFileComponent> Files);
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle component.
|
||||
/// </summary>
|
||||
/// <param name="Name">Filename (e.g., "secrets.ruleset.manifest.json").</param>
|
||||
/// <param name="Digest">SHA256 digest of the file.</param>
|
||||
/// <param name="SizeBytes">File size in bytes.</param>
|
||||
public sealed record RuleBundleFileComponent(
|
||||
string Name,
|
||||
string Digest,
|
||||
long SizeBytes);
|
||||
|
||||
@@ -25,6 +25,7 @@ public sealed class KnowledgeSnapshotManifest
|
||||
public List<VexSnapshotEntry> VexStatements { get; init; } = [];
|
||||
public List<PolicySnapshotEntry> Policies { get; init; } = [];
|
||||
public List<TrustRootSnapshotEntry> TrustRoots { get; init; } = [];
|
||||
public List<RuleBundleSnapshotEntry> RuleBundles { get; init; } = [];
|
||||
public TimeAnchorEntry? TimeAnchor { get; set; }
|
||||
}
|
||||
|
||||
@@ -81,6 +82,79 @@ public sealed class TrustRootSnapshotEntry
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a rule bundle in the snapshot.
|
||||
/// Used for detection rule bundles (secrets, malware, etc.).
|
||||
/// </summary>
|
||||
public sealed class RuleBundleSnapshotEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle type (e.g., "secrets", "malware").
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version in YYYY.MM format.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path to the bundle directory in the snapshot.
|
||||
/// </summary>
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of files in the bundle with their digests.
|
||||
/// </summary>
|
||||
public required List<RuleBundleFile> Files { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules in the bundle.
|
||||
/// </summary>
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used to sign the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle signature was verified during export.
|
||||
/// </summary>
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle.
|
||||
/// </summary>
|
||||
public sealed class RuleBundleFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Filename (e.g., "secrets.ruleset.manifest.json").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the file.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes.
|
||||
/// </summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time anchor entry in the manifest.
|
||||
/// </summary>
|
||||
|
||||
@@ -81,9 +81,64 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
cryptoConfig.ExpiresAt));
|
||||
}
|
||||
|
||||
var ruleBundles = new List<RuleBundleComponent>();
|
||||
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<RuleBundleFileComponent>();
|
||||
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<FeedBuildConfig> Feeds,
|
||||
IReadOnlyList<PolicyBuildConfig> Policies,
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials);
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials,
|
||||
IReadOnlyList<RuleBundleBuildConfig> RuleBundles);
|
||||
|
||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||
|
||||
@@ -169,3 +226,24 @@ public sealed record CryptoBuildConfig(
|
||||
CryptoComponentType Type,
|
||||
DateTimeOffset? ExpiresAt)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for building a rule bundle component.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Bundle identifier (e.g., "secrets.ruleset").</param>
|
||||
/// <param name="BundleType">Bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="Version">Bundle version in YYYY.MM format.</param>
|
||||
/// <param name="SourceDirectory">Source directory containing the rule bundle files.</param>
|
||||
/// <param name="RelativePath">Relative path in the output bundle.</param>
|
||||
/// <param name="RuleCount">Number of rules in the bundle.</param>
|
||||
/// <param name="SignerKeyId">Key ID used to sign the bundle.</param>
|
||||
/// <param name="SignedAt">When the bundle was signed.</param>
|
||||
public sealed record RuleBundleBuildConfig(
|
||||
string BundleId,
|
||||
string BundleType,
|
||||
string Version,
|
||||
string SourceDirectory,
|
||||
string RelativePath,
|
||||
int RuleCount,
|
||||
string? SignerKeyId,
|
||||
DateTimeOffset? SignedAt);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<RuleBundleFile>();
|
||||
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<VexContent> VexStatements { get; init; } = [];
|
||||
public List<PolicyContent> Policies { get; init; } = [];
|
||||
public List<TrustRootContent> TrustRoots { get; init; } = [];
|
||||
public List<RuleBundleContent> RuleBundles { get; init; } = [];
|
||||
public TimeAnchorContent? TimeAnchor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
@@ -445,6 +492,68 @@ public sealed record TrustRootContent
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for a rule bundle (e.g., secrets detection rules).
|
||||
/// </summary>
|
||||
public sealed record RuleBundleContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle type (e.g., "secrets", "malware").
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version in YYYY.MM format.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files in the bundle.
|
||||
/// </summary>
|
||||
public required List<RuleBundleFileContent> Files { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules in the bundle.
|
||||
/// </summary>
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used to sign the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle signature was verified during export.
|
||||
/// </summary>
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleFileContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Filename (e.g., "secrets.ruleset.manifest.json").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File content.
|
||||
/// </summary>
|
||||
public required byte[] Content { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TimeAnchorContent
|
||||
{
|
||||
public required DateTimeOffset AnchorTime { get; init; }
|
||||
|
||||
@@ -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<RuleBundleValidator>.Instance);
|
||||
}
|
||||
|
||||
private async Task<string> 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<MonotonicityCheckResult> 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<MonotonicityCheckResult> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,18 @@ public interface IOfflineRootStore
|
||||
Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a rule bundle signing key by ID and bundle type.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier.</param>
|
||||
/// <param name="bundleType">The bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The envelope key if found, null otherwise.</returns>
|
||||
Task<StellaOps.Attestor.Envelope.EnvelopeKey?> GetRuleBundleSigningKeyAsync(
|
||||
string keyId,
|
||||
string bundleType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,7 +93,9 @@ public enum RootType
|
||||
/// <summary>Organization signing keys for bundle endorsement.</summary>
|
||||
OrgSigning,
|
||||
/// <summary>Rekor public keys for transparency log verification.</summary>
|
||||
Rekor
|
||||
Rekor,
|
||||
/// <summary>Rule bundle signing keys for secrets/malware rule bundles.</summary>
|
||||
RuleBundleSigning
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for verifying rule bundle (secrets, malware, etc.) signatures offline.
|
||||
/// Enables air-gapped environments to verify rule bundle signatures using
|
||||
/// locally stored signing keys.
|
||||
/// </summary>
|
||||
public interface IRuleBundleSignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify a rule bundle signature.
|
||||
/// </summary>
|
||||
/// <param name="request">The verification request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result with detailed status.</returns>
|
||||
Task<RuleBundleSignatureResult> VerifyAsync(
|
||||
RuleBundleSignatureRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a rule bundle from a directory.
|
||||
/// </summary>
|
||||
/// <param name="bundleDirectory">Directory containing the rule bundle.</param>
|
||||
/// <param name="bundleId">Expected bundle identifier.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<RuleBundleSignatureResult> VerifyDirectoryAsync(
|
||||
string bundleDirectory,
|
||||
string bundleId,
|
||||
RuleBundleVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for verifying a rule bundle signature.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleSignatureRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE envelope containing the signature.
|
||||
/// </summary>
|
||||
public required byte[] EnvelopeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The payload (manifest) that was signed.
|
||||
/// </summary>
|
||||
public required byte[] PayloadBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected bundle identifier.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected bundle type (e.g., "secrets", "malware").
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected bundle version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID that should have signed the bundle (optional).
|
||||
/// </summary>
|
||||
public string? ExpectedKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of rule bundle signature verification.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleSignatureResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID that signed the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm used for signing.
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the signature was verified.
|
||||
/// </summary>
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification issues.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VerificationIssue> Issues { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful result.
|
||||
/// </summary>
|
||||
public static RuleBundleSignatureResult Success(
|
||||
string signerKeyId,
|
||||
string algorithm,
|
||||
DateTimeOffset verifiedAt) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
SignerKeyId = signerKeyId,
|
||||
Algorithm = algorithm,
|
||||
VerifiedAt = verifiedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed result.
|
||||
/// </summary>
|
||||
public static RuleBundleSignatureResult Failure(
|
||||
string error,
|
||||
DateTimeOffset verifiedAt,
|
||||
IReadOnlyList<VerificationIssue>? issues = null) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Error = error,
|
||||
VerifiedAt = verifiedAt,
|
||||
Issues = issues ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for rule bundle verification.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleVerificationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the signing key file.
|
||||
/// </summary>
|
||||
public string? SigningKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected signer key ID.
|
||||
/// </summary>
|
||||
public string? ExpectedKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require a valid signature.
|
||||
/// </summary>
|
||||
public bool RequireSignature { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use strict mode (fail on any warning).
|
||||
/// </summary>
|
||||
public bool StrictMode { get; init; }
|
||||
}
|
||||
@@ -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<string, EnvelopeKey> _ruleBundleKeyCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
@@ -75,6 +79,20 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
return _rekorKeys ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get rule bundle signing key certificates.
|
||||
/// </summary>
|
||||
public async Task<X509Certificate2Collection> GetRuleBundleSigningKeysAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_ruleBundleSigningKeys == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.RuleBundleSigning, cancellationToken);
|
||||
}
|
||||
|
||||
return _ruleBundleSigningKeys ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ImportRootsAsync(
|
||||
string pemPath,
|
||||
@@ -160,6 +178,66 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EnvelopeKey?> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RootCertificateInfo>> 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<EnvelopeKey?> 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
|
||||
/// </summary>
|
||||
public string? RekorBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to rule bundle signing keys (file or directory).
|
||||
/// </summary>
|
||||
public string? RuleBundleSigningPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to Offline Kit installation.
|
||||
/// </summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies rule bundle (secrets, malware, etc.) signatures offline.
|
||||
/// </summary>
|
||||
public sealed class RuleBundleSignatureVerifier : IRuleBundleSignatureVerifier
|
||||
{
|
||||
private readonly IOfflineRootStore _rootStore;
|
||||
private readonly EnvelopeSignatureService _signatureService = new();
|
||||
private readonly ILogger<RuleBundleSignatureVerifier> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RuleBundleSignatureVerifier(
|
||||
IOfflineRootStore rootStore,
|
||||
ILogger<RuleBundleSignatureVerifier> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleBundleSignatureResult> 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<VerificationIssue>();
|
||||
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleBundleSignatureResult> 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<VerificationIssue>();
|
||||
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<DsseEnvelope>(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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope structure for parsing.
|
||||
/// </summary>
|
||||
internal sealed class DsseEnvelope
|
||||
{
|
||||
public string PayloadType { get; set; } = string.Empty;
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
public List<DsseSignature> Signatures { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature structure.
|
||||
/// </summary>
|
||||
internal sealed class DsseSignature
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
public string Sig { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -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<ScanMetricsCollector> _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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,7 +87,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
@@ -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<FalsifiabilityGenerator> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public FalsifiabilityGenerator(ILogger<FalsifiabilityGenerator> logger)
|
||||
public FalsifiabilityGenerator(
|
||||
ILogger<FalsifiabilityGenerator> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,4 +12,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<DockerConnectionTester> _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<DockerConnectionTester> logger)
|
||||
ILogger<DockerConnectionTester> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<GitConnectionTester> _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<GitConnectionTester> logger)
|
||||
ILogger<GitConnectionTester> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> 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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<ZastavaConnectionTester> _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<ZastavaConnectionTester> logger)
|
||||
ILogger<ZastavaConnectionTester> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> 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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<CliSourceHandler> _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<CliSourceHandler> logger)
|
||||
ILogger<CliSourceHandler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<string, object>
|
||||
{
|
||||
["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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly IImageDiscoveryService _discoveryService;
|
||||
private readonly ILogger<DockerSourceHandler> _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<DockerSourceHandler> logger)
|
||||
ILogger<DockerSourceHandler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_discoveryService = discoveryService;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> 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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<GitSourceHandler> _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<GitSourceHandler> logger)
|
||||
ILogger<GitSourceHandler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_gitClientFactory = gitClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> 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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<ZastavaSourceHandler> _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<ZastavaSourceHandler> logger)
|
||||
ILogger<ZastavaSourceHandler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> 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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,15 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
private const string Schema = "scanner";
|
||||
private const string Table = "sbom_sources";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SbomSourceRepository(
|
||||
ScannerSourcesDataSource dataSource,
|
||||
ILogger<SbomSourceRepository> logger)
|
||||
ILogger<SbomSourceRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
@@ -317,7 +320,7 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default)
|
||||
{
|
||||
return GetDueScheduledSourcesAsync(DateTimeOffset.UtcNow, 100, ct);
|
||||
return GetDueScheduledSourcesAsync(_timeProvider.GetUtcNow(), 100, ct);
|
||||
}
|
||||
|
||||
private void ConfigureSourceParams(NpgsqlCommand cmd, SbomSource source)
|
||||
|
||||
@@ -16,12 +16,15 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
|
||||
private const string Schema = "scanner";
|
||||
private const string Table = "sbom_source_runs";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SbomSourceRunRepository(
|
||||
ScannerSourcesDataSource dataSource,
|
||||
ILogger<SbomSourceRunRepository> logger)
|
||||
ILogger<SbomSourceRunRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default)
|
||||
@@ -188,7 +191,7 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "threshold", DateTimeOffset.UtcNow - olderThan);
|
||||
AddParameter(cmd, "threshold", _timeProvider.GetUtcNow() - olderThan);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRun,
|
||||
|
||||
@@ -17,19 +17,22 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ISourceConnectionTester _connectionTester;
|
||||
private readonly ILogger<SbomSourceService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SbomSourceService(
|
||||
ISbomSourceRepository sourceRepository,
|
||||
ISbomSourceRunRepository runRepository,
|
||||
ISourceConfigValidator configValidator,
|
||||
ISourceConnectionTester connectionTester,
|
||||
ILogger<SbomSourceService> logger)
|
||||
ILogger<SbomSourceService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_sourceRepository = sourceRepository;
|
||||
_runRepository = runRepository;
|
||||
_configValidator = configValidator;
|
||||
_connectionTester = connectionTester;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SourceResponse?> 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);
|
||||
|
||||
@@ -12,13 +12,16 @@ public sealed class SourceConnectionTester : ISourceConnectionTester
|
||||
{
|
||||
private readonly IEnumerable<ISourceTypeConnectionTester> _testers;
|
||||
private readonly ILogger<SourceConnectionTester> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SourceConnectionTester(
|
||||
IEnumerable<ISourceTypeConnectionTester> testers,
|
||||
ILogger<SourceConnectionTester> logger)
|
||||
ILogger<SourceConnectionTester> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_testers = testers;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ConnectionTestResult> 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<string, object>
|
||||
{
|
||||
["exceptionType"] = ex.GetType().Name
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<ISourceTypeHandler> _handlers;
|
||||
private readonly IScanJobQueue _scanJobQueue;
|
||||
private readonly ILogger<SourceTriggerDispatcher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public SourceTriggerDispatcher(
|
||||
ISbomSourceRepository sourceRepository,
|
||||
ISbomSourceRunRepository runRepository,
|
||||
IEnumerable<ISourceTypeHandler> handlers,
|
||||
IScanJobQueue scanJobQueue,
|
||||
ILogger<SourceTriggerDispatcher> logger)
|
||||
ILogger<SourceTriggerDispatcher> 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<TriggerDispatchResult> 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() }
|
||||
};
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ public sealed class SlicePullService : IDisposable
|
||||
private readonly OciRegistryAuthorization _authorization;
|
||||
private readonly SlicePullOptions _options;
|
||||
private readonly ILogger<SlicePullService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Dictionary<string, CachedSlice> _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<SlicePullService>? logger = null)
|
||||
ILogger<SlicePullService>? 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<SlicePullService>.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;
|
||||
}
|
||||
|
||||
@@ -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<Guid> 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;
|
||||
|
||||
@@ -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<PostgresIdempotencyKeyRepository> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
|
||||
public PostgresIdempotencyKeyRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresIdempotencyKeyRepository> logger)
|
||||
ILogger<PostgresIdempotencyKeyRepository> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -68,7 +72,7 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
||||
{
|
||||
if (key.KeyId == Guid.Empty)
|
||||
{
|
||||
key.KeyId = Guid.NewGuid();
|
||||
key.KeyId = _guidProvider.NewGuid();
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
|
||||
@@ -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<ScannerDataSou
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresProofSpineRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresProofSpineRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public Task<ProofSpineModel?> GetByIdAsync(string spineId, CancellationToken cancellationToken = default)
|
||||
@@ -249,7 +253,7 @@ public sealed class PostgresProofSpineRepository : RepositoryBase<ScannerDataSou
|
||||
await using (var command = CreateCommand(insertHistory, connection))
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
AddParameter(command, "id", Guid.NewGuid().ToString("N"));
|
||||
AddParameter(command, "id", _guidProvider.NewGuid().ToString("N"));
|
||||
AddParameter(command, "old_spine_id", oldSpineId.Trim());
|
||||
AddParameter(command, "new_spine_id", newSpineId.Trim());
|
||||
AddParameter(command, "reason", reason);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
@@ -19,13 +20,16 @@ public sealed class PostgresScanMetricsRepository : IScanMetricsRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresScanMetricsRepository> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresScanMetricsRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresScanMetricsRepository> logger)
|
||||
ILogger<PostgresScanMetricsRepository> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ScannerDataSource>
|
||||
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<RuntimeEventRepository> logger)
|
||||
public RuntimeEventRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<RuntimeEventRepository> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<RuntimeEventInsertResult> InsertAsync(
|
||||
@@ -52,7 +58,7 @@ public sealed class RuntimeEventRepository : RepositoryBase<ScannerDataSource>
|
||||
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,
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\Router\\__Libraries\\StellaOps.Messaging\\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Determinism.Abstractions\\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<PostgresVulnSurfaceRepository> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
public PostgresVulnSurfaceRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresVulnSurfaceRepository> 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 (
|
||||
|
||||
@@ -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<string, SignerAuditEntry> _entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<InMemorySignerAuditSink> _logger;
|
||||
|
||||
public InMemorySignerAuditSink(TimeProvider timeProvider, ILogger<InMemorySignerAuditSink> logger)
|
||||
public InMemorySignerAuditSink(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemorySignerAuditSink> 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(),
|
||||
|
||||
@@ -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<DefaultSigningKeyResolver> _logger;
|
||||
|
||||
public DefaultSigningKeyResolver(
|
||||
IOptions<DsseSignerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultSigningKeyResolver> logger)
|
||||
ILogger<DefaultSigningKeyResolver> 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(
|
||||
|
||||
@@ -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<SigstoreSigningService> _logger;
|
||||
|
||||
public SigstoreSigningService(
|
||||
IFulcioClient fulcioClient,
|
||||
IRekorClient rekorClient,
|
||||
IOptions<SigstoreOptions> options,
|
||||
ILogger<SigstoreSigningService> logger)
|
||||
ILogger<SigstoreSigningService> 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(
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<SignerEntitlementOptions>(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",
|
||||
|
||||
@@ -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<KeyRotationService> _logger;
|
||||
private readonly KeyRotationOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public KeyRotationService(
|
||||
KeyManagementDbContext dbContext,
|
||||
ILogger<KeyRotationService> logger,
|
||||
IOptions<KeyRotationOptions> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\*.sql">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
||||
@@ -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<TrustAnchorManager> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public TrustAnchorManager(
|
||||
KeyManagementDbContext dbContext,
|
||||
IKeyRotationService keyRotationService,
|
||||
ILogger<TrustAnchorManager> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<AmbientOidcTokenProvider> logger)
|
||||
ILogger<AmbientOidcTokenProvider> 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;
|
||||
|
||||
@@ -47,7 +47,8 @@ public sealed class EphemeralKeyPair : IDisposable
|
||||
/// <param name="publicKey">The public key bytes.</param>
|
||||
/// <param name="privateKey">The private key bytes (will be copied).</param>
|
||||
/// <param name="algorithm">The algorithm identifier.</param>
|
||||
public EphemeralKeyPair(byte[] publicKey, byte[] privateKey, string algorithm)
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamp.</param>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -75,9 +75,11 @@ public sealed record FulcioCertificateResult(
|
||||
public TimeSpan Validity => NotAfter - NotBefore;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the certificate is currently valid.
|
||||
/// Checks if the certificate is valid at the specified time.
|
||||
/// </summary>
|
||||
public bool IsValid => DateTimeOffset.UtcNow >= NotBefore && DateTimeOffset.UtcNow <= NotAfter;
|
||||
/// <param name="at">The time to check validity against.</param>
|
||||
/// <returns>True if the certificate is valid at the specified time.</returns>
|
||||
public bool IsValidAt(DateTimeOffset at) => at >= NotBefore && at <= NotAfter;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full certificate chain including the leaf certificate.
|
||||
|
||||
@@ -62,15 +62,20 @@ public sealed record OidcTokenResult
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the token is expired.
|
||||
/// Checks whether the token is expired at the specified time.
|
||||
/// </summary>
|
||||
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
/// <param name="now">The time to check against.</param>
|
||||
/// <returns>True if the token is expired.</returns>
|
||||
public bool IsExpiredAt(DateTimeOffset now) => now >= ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the token will expire within the specified buffer time.
|
||||
/// Checks whether the token will expire within the specified buffer time.
|
||||
/// </summary>
|
||||
public bool WillExpireSoon(TimeSpan buffer) =>
|
||||
DateTimeOffset.UtcNow.Add(buffer) >= ExpiresAt;
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <param name="buffer">The time buffer before expiration.</param>
|
||||
/// <returns>True if the token will expire soon.</returns>
|
||||
public bool WillExpireSoon(DateTimeOffset now, TimeSpan buffer) =>
|
||||
now.Add(buffer) >= ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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<PostgresConsensusProjectionStore> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresConsensusProjectionStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresConsensusProjectionStore> 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,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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<IResult> GateSnapshotAsync(
|
||||
|
||||
@@ -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<GenerateRationaleResponse> 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,
|
||||
|
||||
@@ -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<ComputeConsensusResponse> 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<WeightedStatement>();
|
||||
|
||||
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<WeightedStatement>();
|
||||
|
||||
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<ProjectionDetailResponse?> 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<IssuerListResponse> ListIssuersAsync(
|
||||
|
||||
@@ -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<TrustScorecardResponse> 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;
|
||||
|
||||
|
||||
@@ -138,14 +138,16 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
||||
private readonly Dictionary<string, CacheEntry> _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<DetailedConsensusRationale?> GetAsync(
|
||||
@@ -163,7 +165,7 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
||||
return Task.FromResult<DetailedConsensusRationale?>(null);
|
||||
}
|
||||
|
||||
entry.LastAccessed = DateTimeOffset.UtcNow;
|
||||
entry.LastAccessed = _timeProvider.GetUtcNow();
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
return Task.FromResult<DetailedConsensusRationale?>(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)
|
||||
|
||||
@@ -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<VexConsensusResult> 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)
|
||||
|
||||
@@ -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<ConsensusSnapshot> 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
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
/// </summary>
|
||||
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<NormalizedStatement> ExtractStatements(
|
||||
private IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
JsonElement root,
|
||||
List<NormalizationWarning> 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<NormalizationWarning> 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,
|
||||
|
||||
@@ -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<ComputePayload>(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<BatchComputePayload>(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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<VexProofStatement> _statements = [];
|
||||
private readonly List<VexProofMergeStep> _mergeSteps = [];
|
||||
private readonly List<VexProofConflict> _conflicts = [];
|
||||
@@ -48,11 +50,12 @@ public sealed class VexProofBuilder
|
||||
private decimal _conditionCoverage = 1.0m;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new VexProofBuilder with the specified time provider.
|
||||
/// Creates a new VexProofBuilder with the specified time provider and GUID provider.
|
||||
/// </summary>
|
||||
public VexProofBuilder(TimeProvider timeProvider)
|
||||
public VexProofBuilder(TimeProvider timeProvider, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
<!-- NG-001: Noise-gating dependencies -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
||||
<ProjectReference Include="..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
|
||||
<!-- DET-015: Determinism abstractions for TimeProvider and IGuidProvider -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude legacy folders with external dependencies -->
|
||||
|
||||
@@ -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<ConsensusProjection> 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,
|
||||
|
||||
@@ -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<PostgresConsensusProjectionStoreProxy> _logger;
|
||||
private readonly VexLensStorageOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresConsensusProjectionStoreProxy(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresConsensusProjectionStoreProxy> 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,
|
||||
|
||||
@@ -65,9 +65,9 @@ public sealed record SourceTrustScoreRequest
|
||||
public required SourceVerificationSummary VerificationSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time at which to evaluate the score.
|
||||
/// Time at which to evaluate the score. Required for determinism.
|
||||
/// </summary>
|
||||
public DateTimeOffset EvaluationTime { get; init; } = DateTimeOffset.UtcNow;
|
||||
public required DateTimeOffset EvaluationTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous score for trend calculation.
|
||||
|
||||
@@ -9,16 +9,18 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _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<VexSourceTrustScore?> 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<VexSourceTrustScore?>(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)
|
||||
|
||||
@@ -11,13 +11,16 @@ public sealed class ProvenanceChainValidator : IProvenanceChainValidator
|
||||
{
|
||||
private readonly ILogger<ProvenanceChainValidator> _logger;
|
||||
private readonly IIssuerDirectory _issuerDirectory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ProvenanceChainValidator(
|
||||
ILogger<ProvenanceChainValidator> logger,
|
||||
IIssuerDirectory issuerDirectory)
|
||||
IIssuerDirectory issuerDirectory,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_issuerDirectory = issuerDirectory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ProvenanceValidationResult> 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
|
||||
|
||||
@@ -11,6 +11,12 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IssuerRecord> _issuers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _fingerprintToIssuer = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryIssuerDirectory(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<IssuerRecord?> 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<KeyFingerprintRecord>();
|
||||
|
||||
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}");
|
||||
|
||||
@@ -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<ZastavaAgentOptions> _options;
|
||||
private readonly ILogger<RuntimeEventsClient> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public RuntimeEventsClient(
|
||||
HttpClient httpClient,
|
||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||
ILogger<RuntimeEventsClient> logger)
|
||||
ILogger<RuntimeEventsClient> 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<RuntimeEventsSubmitResult> 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()
|
||||
};
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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<ZastavaAgentOptions> _options;
|
||||
private readonly ILogger<HealthCheckHostedService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private HttpListener? _listener;
|
||||
|
||||
public HealthCheckHostedService(
|
||||
IDockerSocketClient dockerClient,
|
||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||
ILogger<HealthCheckHostedService> logger)
|
||||
ILogger<HealthCheckHostedService> 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;
|
||||
|
||||
@@ -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<RuntimeEventBuffer> _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<ZastavaAgentOptions> agentOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeEventBuffer> logger)
|
||||
ILogger<RuntimeEventBuffer> 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<string> 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);
|
||||
|
||||
@@ -16,18 +16,21 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
private readonly IRuntimeEventsClient _eventsClient;
|
||||
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
||||
private readonly ILogger<RuntimeEventDispatchService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Random _jitterRandom = new();
|
||||
|
||||
public RuntimeEventDispatchService(
|
||||
IRuntimeEventBuffer eventBuffer,
|
||||
IRuntimeEventsClient eventsClient,
|
||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||
ILogger<RuntimeEventDispatchService> logger)
|
||||
ILogger<RuntimeEventDispatchService> 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<RuntimeEventBufferItem>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, SignalCollectionHandle> _activeHandles;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -30,12 +31,14 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable
|
||||
ILogger<EbpfProbeManager> logger,
|
||||
IRuntimeSignalCollector signalCollector,
|
||||
ISignalPublisher signalPublisher,
|
||||
IOptions<EbpfProbeManagerOptions> options)
|
||||
IOptions<EbpfProbeManagerOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_signalCollector = signalCollector;
|
||||
_signalPublisher = signalPublisher;
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_activeHandles = new ConcurrentDictionary<string, SignalCollectionHandle>();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -30,10 +30,12 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
||||
private readonly DotNetAssemblyCollector _dotnetCollector;
|
||||
private readonly PhpAutoloadCollector _phpCollector;
|
||||
private readonly ILogger<ProcSnapshotCollector> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _procRoot;
|
||||
|
||||
public ProcSnapshotCollector(
|
||||
IOptions<ZastavaObserverOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
@@ -41,6 +43,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
||||
|
||||
_procRoot = options.Value.ProcRootPath;
|
||||
_logger = loggerFactory.CreateLogger<ProcSnapshotCollector>();
|
||||
_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(
|
||||
|
||||
@@ -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<RuntimeEventBuffer> 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<ZastavaObserverOptions> observerOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeEventBuffer> logger)
|
||||
ILogger<RuntimeEventBuffer> 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<string> 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);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos/runtime/v1/runtime.proto" GrpcServices="Client" />
|
||||
|
||||
@@ -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<ZastavaObserverOptions> observerOptions;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IGuidProvider guidProvider;
|
||||
private readonly ILogger<RuntimeEventDispatchService> logger;
|
||||
|
||||
public RuntimeEventDispatchService(
|
||||
@@ -25,6 +27,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
IRuntimeFactsClient runtimeFactsClient,
|
||||
IOptionsMonitor<ZastavaObserverOptions> observerOptions,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider? guidProvider,
|
||||
ILogger<RuntimeEventDispatchService> 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
|
||||
};
|
||||
|
||||
|
||||
@@ -6,14 +6,17 @@ public sealed class WebhookCertificateHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IWebhookCertificateProvider _certificateProvider;
|
||||
private readonly ILogger<WebhookCertificateHealthCheck> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _expiryThreshold = TimeSpan.FromDays(7);
|
||||
|
||||
public WebhookCertificateHealthCheck(
|
||||
IWebhookCertificateProvider certificateProvider,
|
||||
ILogger<WebhookCertificateHealthCheck> logger)
|
||||
ILogger<WebhookCertificateHealthCheck> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_certificateProvider = certificateProvider;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> 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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user