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_CACHE_ROOT: "/var/lib/stellaops/surface"
|
||||||
SCANNER_SURFACE_SECRETS_PROVIDER: "file"
|
SCANNER_SURFACE_SECRETS_PROVIDER: "file"
|
||||||
SCANNER_SURFACE_SECRETS_ROOT: "/etc/stellaops/secrets"
|
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:
|
notify-web:
|
||||||
image: registry.stella-ops.org/stellaops/notify-web:2025.09.2
|
image: registry.stella-ops.org/stellaops/notify-web:2025.09.2
|
||||||
service:
|
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 |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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/**`. |
|
| **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/**`). |
|
| **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.
|
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:**
|
**Python analyzer features:**
|
||||||
- **Wheel/sdist/editable** parsing with dependency edges from `METADATA`, `PKG-INFO`, `requirements.txt`, and `pyproject.toml`
|
- **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
|
- **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 |
|
| Goal | Open this |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Understand the product in 2 minutes | `overview.md` |
|
| Understand the product in 2 minutes | [overview.md](/docs/overview/) |
|
||||||
| Run a first scan (CLI) | `quickstart.md` |
|
| Run a first scan (CLI) | [quickstart.md](/docs/quickstart/) |
|
||||||
| Browse capabilities | `key-features.md` |
|
| Browse capabilities | [key-features.md](/docs/key-features/) |
|
||||||
| Roadmap (priorities + definition of "done") | `05_ROADMAP.md` |
|
| Roadmap (priorities + definition of "done") | [05_ROADMAP.md](/docs/05_roadmap/) |
|
||||||
| Architecture: high-level overview | `40_ARCHITECTURE_OVERVIEW.md` |
|
| Architecture: high-level overview | [40_ARCHITECTURE_OVERVIEW.md](/docs/40_architecture_overview/) |
|
||||||
| Architecture: full reference map | `07_HIGH_LEVEL_ARCHITECTURE.md` |
|
| Architecture: full reference map | [07_HIGH_LEVEL_ARCHITECTURE.md](/docs/07_high_level_architecture/) |
|
||||||
| Architecture: user flows (UML) | `technical/architecture/user-flows.md` |
|
| Architecture: user flows (UML) | [technical/architecture/user-flows.md](/docs/technical/architecture/user-flows/) |
|
||||||
| Architecture: module matrix (46 modules) | `technical/architecture/module-matrix.md` |
|
| Architecture: module matrix (46 modules) | [technical/architecture/module-matrix.md](/docs/technical/architecture/module-matrix/) |
|
||||||
| Architecture: data flows | `technical/architecture/data-flows.md` |
|
| Architecture: data flows | [technical/architecture/data-flows.md](/docs/technical/architecture/data-flows/) |
|
||||||
| Architecture: schema mapping | `technical/architecture/schema-mapping.md` |
|
| Architecture: schema mapping | [technical/architecture/schema-mapping.md](/docs/technical/architecture/schema-mapping/) |
|
||||||
| Offline / air-gap operations | `24_OFFLINE_KIT.md` |
|
| Offline / air-gap operations | [24_OFFLINE_KIT.md](/docs/24_offline_kit/) |
|
||||||
| Security deployment hardening | `17_SECURITY_HARDENING_GUIDE.md` |
|
| Security deployment hardening | [17_SECURITY_HARDENING_GUIDE.md](/docs/17_security_hardening_guide/) |
|
||||||
| Ingest advisories (Concelier + CLI) | `10_CONCELIER_CLI_QUICKSTART.md` |
|
| Ingest advisories (Concelier + CLI) | [10_CONCELIER_CLI_QUICKSTART.md](/docs/10_concelier_cli_quickstart/) |
|
||||||
| Develop plugins/connectors | `10_PLUGIN_SDK_GUIDE.md` |
|
| Develop plugins/connectors | [10_PLUGIN_SDK_GUIDE.md](/docs/10_plugin_sdk_guide/) |
|
||||||
| Console (Web UI) operator guide | `15_UI_GUIDE.md` |
|
| Console (Web UI) operator guide | [15_UI_GUIDE.md](/docs/15_ui_guide/) |
|
||||||
| VEX consensus and issuer trust | `16_VEX_CONSENSUS_GUIDE.md` |
|
| VEX consensus and issuer trust | [16_VEX_CONSENSUS_GUIDE.md](/docs/16_vex_consensus_guide/) |
|
||||||
| Vulnerability Explorer guide | `20_VULNERABILITY_EXPLORER_GUIDE.md` |
|
| Vulnerability Explorer guide | [20_VULNERABILITY_EXPLORER_GUIDE.md](/docs/20_vulnerability_explorer_guide/) |
|
||||||
|
|
||||||
## Detailed Indexes
|
## Detailed Indexes
|
||||||
|
|
||||||
- **Technical index (everything):** `docs/technical/README.md`
|
- **Technical index (everything):** [docs/technical/README.md](/docs/technical/)
|
||||||
- **End-to-end workflow flows:** `docs/flows/` (16 detailed flow documents)
|
- **End-to-end workflow flows:** [docs/flows/](/docs/flows/) (16 detailed flow documents)
|
||||||
- **Module dossiers:** `docs/modules/`
|
- **Module dossiers:** [docs/modules/](/docs/modules/)
|
||||||
- **API contracts and samples:** `docs/api/`
|
- **API contracts and samples:** [docs/api/](/docs/api/)
|
||||||
- **Architecture notes / ADRs:** `docs/architecture/`, `docs/adr/`
|
- **Architecture notes / ADRs:** [docs/architecture/](/docs/architecture/), [docs/adr/](/docs/adr/)
|
||||||
- **Operations and deployment:** `docs/operations/`, `docs/deploy/`, `docs/deployment/`
|
- **Operations and deployment:** [docs/operations/](/docs/operations/), [docs/deploy/](/docs/deploy/), [docs/deployment/](/docs/deployment/)
|
||||||
- **Air-gap workflows:** `docs/airgap/`
|
- **Air-gap workflows:** [docs/airgap/](/docs/airgap/)
|
||||||
- **Security deep dives:** `docs/security/`
|
- **Security deep dives:** [docs/security/](/docs/security/)
|
||||||
- **Benchmarks and fixtures:** `docs/benchmarks/`, `docs/assets/`
|
- **Benchmarks and fixtures:** [docs/benchmarks/](/docs/benchmarks/), [docs/assets/](/docs/assets/)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -64,13 +64,13 @@
|
|||||||
| 8 | DET-008 | DONE | DET-002, DET-003 | Guild | Refactor Registry module (1 file: RegistryTokenIssuer) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 18 | DET-018 | TODO | DET-004 to DET-017 | Guild | Final audit: verify zero direct DateTime/Guid/Random calls in production code |
|
||||||
|
|
||||||
## Implementation Pattern
|
## 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-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 | 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 | 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
|
## Decisions & Risks
|
||||||
- **Decision:** Defer determinism refactoring from MAINT audit to dedicated sprint for focused, systematic approach.
|
- **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 |
|
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| 1 | OKS-001 | TODO | None | AirGap Guild | Update Offline Kit manifest schema for rules |
|
| 1 | OKS-001 | DONE | None | AirGap Guild | Update Offline Kit manifest schema for rules |
|
||||||
| 2 | OKS-002 | TODO | OKS-001 | AirGap Guild | Add secrets bundle to BundleBuilder |
|
| 2 | OKS-002 | DONE | OKS-001 | AirGap Guild | Add secrets bundle to BundleBuilder |
|
||||||
| 3 | OKS-003 | TODO | OKS-002 | AirGap Guild | Create bundle verification in Importer |
|
| 3 | OKS-003 | DONE | OKS-002 | AirGap Guild | Create bundle verification in Importer |
|
||||||
| 4 | OKS-004 | TODO | None | AirGap Guild | Add Attestor mirror support for bundle verification |
|
| 4 | OKS-004 | DONE | None | AirGap Guild | Add Attestor mirror support for bundle verification |
|
||||||
| 5 | OKS-005 | TODO | OKS-003 | AirGap Guild | Create bundle installation script |
|
| 5 | OKS-005 | DONE | OKS-003 | AirGap Guild | Create bundle installation script |
|
||||||
| 6 | OKS-006 | TODO | OKS-005 | AirGap Guild | Add bundle rotation/upgrade workflow |
|
| 6 | OKS-006 | DONE | OKS-005 | AirGap Guild | Add bundle rotation/upgrade workflow |
|
||||||
| 7 | OKS-007 | TODO | None | CI/CD Guild | Add bundle to release workflow |
|
| 7 | OKS-007 | DONE | None | CI/CD Guild | Add bundle to release workflow |
|
||||||
| 8 | OKS-008 | TODO | All | AirGap Guild | Add integration tests for offline flow |
|
| 8 | OKS-008 | DONE | All | AirGap Guild | Add integration tests for offline flow |
|
||||||
| 9 | OKS-009 | TODO | All | Docs Guild | Update offline kit documentation |
|
| 9 | OKS-009 | DONE | All | Docs Guild | Update offline kit documentation |
|
||||||
| 10 | OKS-010 | TODO | All | DevOps Guild | Update Helm charts for bundle mounting |
|
| 10 | OKS-010 | DONE | All | DevOps Guild | Update Helm charts for bundle mounting |
|
||||||
|
|
||||||
## Task Details
|
## Task Details
|
||||||
|
|
||||||
@@ -588,4 +588,17 @@ devops/offline/
|
|||||||
| Date | Action | Notes |
|
| Date | Action | Notes |
|
||||||
|------|--------|-------|
|
|------|--------|-------|
|
||||||
| 2026-01-04 | Sprint created | Part of secret leak detection implementation |
|
| 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
|
return values
|
||||||
.Select(static value => value?.Trim())
|
.Select(static value => value?.Trim())
|
||||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(static value => value!)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -203,6 +204,7 @@ public sealed record OfflineCertConstraints
|
|||||||
return values
|
return values
|
||||||
.Select(static value => value?.Trim())
|
.Select(static value => value?.Trim())
|
||||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(static value => value!)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public static class JsonNormalizer
|
|||||||
}
|
}
|
||||||
|
|
||||||
var normalized = NormalizeNode(node, options);
|
var normalized = NormalizeNode(node, options);
|
||||||
return normalized.ToJsonString(SerializerOptions);
|
return normalized?.ToJsonString(SerializerOptions) ?? "null";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ public sealed class SbomNormalizer
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private JsonNode NormalizeGeneric(JsonNode node)
|
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>
|
/// <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 ImmutableArray<CatalogComponent> Catalogs { get; init; } = [];
|
||||||
public RekorSnapshot? RekorSnapshot { get; init; }
|
public RekorSnapshot? RekorSnapshot { get; init; }
|
||||||
public ImmutableArray<CryptoProviderComponent> CryptoProviders { get; init; } = [];
|
public ImmutableArray<CryptoProviderComponent> CryptoProviders { get; init; } = [];
|
||||||
|
public ImmutableArray<RuleBundleComponent> RuleBundles { get; init; } = [];
|
||||||
public long TotalSizeBytes { get; init; }
|
public long TotalSizeBytes { get; init; }
|
||||||
public string? BundleDigest { get; init; }
|
public string? BundleDigest { get; init; }
|
||||||
}
|
}
|
||||||
@@ -102,3 +103,39 @@ public sealed record CryptoProviderComponent(
|
|||||||
string Digest,
|
string Digest,
|
||||||
long SizeBytes,
|
long SizeBytes,
|
||||||
ImmutableArray<string> SupportedAlgorithms);
|
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<VexSnapshotEntry> VexStatements { get; init; } = [];
|
||||||
public List<PolicySnapshotEntry> Policies { get; init; } = [];
|
public List<PolicySnapshotEntry> Policies { get; init; } = [];
|
||||||
public List<TrustRootSnapshotEntry> TrustRoots { get; init; } = [];
|
public List<TrustRootSnapshotEntry> TrustRoots { get; init; } = [];
|
||||||
|
public List<RuleBundleSnapshotEntry> RuleBundles { get; init; } = [];
|
||||||
public TimeAnchorEntry? TimeAnchor { get; set; }
|
public TimeAnchorEntry? TimeAnchor { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,79 @@ public sealed class TrustRootSnapshotEntry
|
|||||||
public DateTimeOffset? ExpiresAt { get; init; }
|
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>
|
/// <summary>
|
||||||
/// Time anchor entry in the manifest.
|
/// Time anchor entry in the manifest.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -81,9 +81,64 @@ public sealed class BundleBuilder : IBundleBuilder
|
|||||||
cryptoConfig.ExpiresAt));
|
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) +
|
var totalSize = feeds.Sum(f => f.SizeBytes) +
|
||||||
policies.Sum(p => p.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
|
var manifest = new BundleManifest
|
||||||
{
|
{
|
||||||
@@ -96,6 +151,7 @@ public sealed class BundleBuilder : IBundleBuilder
|
|||||||
Feeds = feeds.ToImmutableArray(),
|
Feeds = feeds.ToImmutableArray(),
|
||||||
Policies = policies.ToImmutableArray(),
|
Policies = policies.ToImmutableArray(),
|
||||||
CryptoMaterials = cryptoMaterials.ToImmutableArray(),
|
CryptoMaterials = cryptoMaterials.ToImmutableArray(),
|
||||||
|
RuleBundles = ruleBundles.ToImmutableArray(),
|
||||||
TotalSizeBytes = totalSize
|
TotalSizeBytes = totalSize
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,7 +194,8 @@ public sealed record BundleBuildRequest(
|
|||||||
DateTimeOffset? ExpiresAt,
|
DateTimeOffset? ExpiresAt,
|
||||||
IReadOnlyList<FeedBuildConfig> Feeds,
|
IReadOnlyList<FeedBuildConfig> Feeds,
|
||||||
IReadOnlyList<PolicyBuildConfig> Policies,
|
IReadOnlyList<PolicyBuildConfig> Policies,
|
||||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials);
|
IReadOnlyList<CryptoBuildConfig> CryptoMaterials,
|
||||||
|
IReadOnlyList<RuleBundleBuildConfig> RuleBundles);
|
||||||
|
|
||||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||||
|
|
||||||
@@ -169,3 +226,24 @@ public sealed record CryptoBuildConfig(
|
|||||||
CryptoComponentType Type,
|
CryptoComponentType Type,
|
||||||
DateTimeOffset? ExpiresAt)
|
DateTimeOffset? ExpiresAt)
|
||||||
: BundleComponentSource(SourcePath, RelativePath);
|
: 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));
|
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
|
// Compute merkle root
|
||||||
var computedRoot = ComputeMerkleRoot(entries);
|
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
|
// Write time anchor
|
||||||
if (request.TimeAnchor is not null)
|
if (request.TimeAnchor is not null)
|
||||||
{
|
{
|
||||||
@@ -389,6 +435,7 @@ public sealed record SnapshotBundleRequest
|
|||||||
public List<VexContent> VexStatements { get; init; } = [];
|
public List<VexContent> VexStatements { get; init; } = [];
|
||||||
public List<PolicyContent> Policies { get; init; } = [];
|
public List<PolicyContent> Policies { get; init; } = [];
|
||||||
public List<TrustRootContent> TrustRoots { get; init; } = [];
|
public List<TrustRootContent> TrustRoots { get; init; } = [];
|
||||||
|
public List<RuleBundleContent> RuleBundles { get; init; } = [];
|
||||||
public TimeAnchorContent? TimeAnchor { get; init; }
|
public TimeAnchorContent? TimeAnchor { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -445,6 +492,68 @@ public sealed record TrustRootContent
|
|||||||
public DateTimeOffset? ExpiresAt { get; init; }
|
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 sealed record TimeAnchorContent
|
||||||
{
|
{
|
||||||
public required DateTimeOffset AnchorTime { get; init; }
|
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(
|
Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||||
RootType rootType,
|
RootType rootType,
|
||||||
CancellationToken cancellationToken = default);
|
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>
|
/// <summary>
|
||||||
@@ -81,7 +93,9 @@ public enum RootType
|
|||||||
/// <summary>Organization signing keys for bundle endorsement.</summary>
|
/// <summary>Organization signing keys for bundle endorsement.</summary>
|
||||||
OrgSigning,
|
OrgSigning,
|
||||||
/// <summary>Rekor public keys for transparency log verification.</summary>
|
/// <summary>Rekor public keys for transparency log verification.</summary>
|
||||||
Rekor
|
Rekor,
|
||||||
|
/// <summary>Rule bundle signing keys for secrets/malware rule bundles.</summary>
|
||||||
|
RuleBundleSigning
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Attestor.Envelope;
|
||||||
using StellaOps.Attestor.Offline.Abstractions;
|
using StellaOps.Attestor.Offline.Abstractions;
|
||||||
|
|
||||||
namespace StellaOps.Attestor.Offline.Services;
|
namespace StellaOps.Attestor.Offline.Services;
|
||||||
@@ -26,6 +28,8 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
private X509Certificate2Collection? _fulcioRoots;
|
private X509Certificate2Collection? _fulcioRoots;
|
||||||
private X509Certificate2Collection? _orgSigningKeys;
|
private X509Certificate2Collection? _orgSigningKeys;
|
||||||
private X509Certificate2Collection? _rekorKeys;
|
private X509Certificate2Collection? _rekorKeys;
|
||||||
|
private X509Certificate2Collection? _ruleBundleSigningKeys;
|
||||||
|
private readonly Dictionary<string, EnvelopeKey> _ruleBundleKeyCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -75,6 +79,20 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
return _rekorKeys ?? new X509Certificate2Collection();
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task ImportRootsAsync(
|
public async Task ImportRootsAsync(
|
||||||
string pemPath,
|
string pemPath,
|
||||||
@@ -160,6 +178,66 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
return null;
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
public async Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||||
RootType rootType,
|
RootType rootType,
|
||||||
@@ -170,6 +248,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
RootType.Fulcio => await GetFulcioRootsAsync(cancellationToken),
|
RootType.Fulcio => await GetFulcioRootsAsync(cancellationToken),
|
||||||
RootType.OrgSigning => await GetOrgSigningKeysAsync(cancellationToken),
|
RootType.OrgSigning => await GetOrgSigningKeysAsync(cancellationToken),
|
||||||
RootType.Rekor => await GetRekorKeysAsync(cancellationToken),
|
RootType.Rekor => await GetRekorKeysAsync(cancellationToken),
|
||||||
|
RootType.RuleBundleSigning => await GetRuleBundleSigningKeysAsync(cancellationToken),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(rootType))
|
_ => throw new ArgumentOutOfRangeException(nameof(rootType))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -297,6 +376,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
RootType.Fulcio => _options.FulcioBundlePath ?? "",
|
RootType.Fulcio => _options.FulcioBundlePath ?? "",
|
||||||
RootType.OrgSigning => _options.OrgSigningBundlePath ?? "",
|
RootType.OrgSigning => _options.OrgSigningBundlePath ?? "",
|
||||||
RootType.Rekor => _options.RekorBundlePath ?? "",
|
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.Fulcio => _options.FulcioBundlePath ?? Path.Combine(_options.BaseRootPath, "fulcio"),
|
||||||
RootType.OrgSigning => _options.OrgSigningBundlePath ?? Path.Combine(_options.BaseRootPath, "org-signing"),
|
RootType.OrgSigning => _options.OrgSigningBundlePath ?? Path.Combine(_options.BaseRootPath, "org-signing"),
|
||||||
RootType.Rekor => _options.RekorBundlePath ?? Path.Combine(_options.BaseRootPath, "rekor"),
|
RootType.Rekor => _options.RekorBundlePath ?? Path.Combine(_options.BaseRootPath, "rekor"),
|
||||||
|
RootType.RuleBundleSigning => _options.RuleBundleSigningPath ?? Path.Combine(_options.BaseRootPath, "rule-bundle-signing"),
|
||||||
_ => _options.BaseRootPath
|
_ => _options.BaseRootPath
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -320,6 +401,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
RootType.Fulcio => Path.Combine(_options.OfflineKitPath, "roots", "fulcio"),
|
RootType.Fulcio => Path.Combine(_options.OfflineKitPath, "roots", "fulcio"),
|
||||||
RootType.OrgSigning => Path.Combine(_options.OfflineKitPath, "roots", "org-signing"),
|
RootType.OrgSigning => Path.Combine(_options.OfflineKitPath, "roots", "org-signing"),
|
||||||
RootType.Rekor => Path.Combine(_options.OfflineKitPath, "roots", "rekor"),
|
RootType.Rekor => Path.Combine(_options.OfflineKitPath, "roots", "rekor"),
|
||||||
|
RootType.RuleBundleSigning => Path.Combine(_options.OfflineKitPath, "roots", "rule-bundle-signing"),
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -329,6 +411,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
RootType.Fulcio => _fulcioRoots,
|
RootType.Fulcio => _fulcioRoots,
|
||||||
RootType.OrgSigning => _orgSigningKeys,
|
RootType.OrgSigning => _orgSigningKeys,
|
||||||
RootType.Rekor => _rekorKeys,
|
RootType.Rekor => _rekorKeys,
|
||||||
|
RootType.RuleBundleSigning => _ruleBundleSigningKeys,
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,6 +428,9 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
case RootType.Rekor:
|
case RootType.Rekor:
|
||||||
_rekorKeys = collection;
|
_rekorKeys = collection;
|
||||||
break;
|
break;
|
||||||
|
case RootType.RuleBundleSigning:
|
||||||
|
_ruleBundleSigningKeys = collection;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,9 +447,130 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
|||||||
case RootType.Rekor:
|
case RootType.Rekor:
|
||||||
_rekorKeys = null;
|
_rekorKeys = null;
|
||||||
break;
|
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)
|
private static string ComputeThumbprint(X509Certificate2 cert)
|
||||||
{
|
{
|
||||||
var hash = SHA256.HashData(cert.RawData);
|
var hash = SHA256.HashData(cert.RawData);
|
||||||
@@ -418,6 +625,11 @@ public sealed class OfflineRootStoreOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? RekorBundlePath { get; set; }
|
public string? RekorBundlePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to rule bundle signing keys (file or directory).
|
||||||
|
/// </summary>
|
||||||
|
public string? RuleBundleSigningPath { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Path to Offline Kit installation.
|
/// Path to Offline Kit installation.
|
||||||
/// </summary>
|
/// </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 System.Diagnostics;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.Determinism.Abstractions;
|
||||||
using StellaOps.Scanner.Storage.Models;
|
using StellaOps.Scanner.Storage.Models;
|
||||||
using StellaOps.Scanner.Storage.Repositories;
|
using StellaOps.Scanner.Storage.Repositories;
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ public sealed class ScanMetricsCollector : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly IScanMetricsRepository _repository;
|
private readonly IScanMetricsRepository _repository;
|
||||||
private readonly ILogger<ScanMetricsCollector> _logger;
|
private readonly ILogger<ScanMetricsCollector> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
private readonly Guid _scanId;
|
private readonly Guid _scanId;
|
||||||
private readonly Guid _tenantId;
|
private readonly Guid _tenantId;
|
||||||
@@ -58,7 +61,9 @@ public sealed class ScanMetricsCollector : IDisposable
|
|||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
string artifactDigest,
|
string artifactDigest,
|
||||||
string artifactType,
|
string artifactType,
|
||||||
string scannerVersion)
|
string scannerVersion,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
@@ -67,7 +72,9 @@ public sealed class ScanMetricsCollector : IDisposable
|
|||||||
_artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest));
|
_artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest));
|
||||||
_artifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType));
|
_artifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType));
|
||||||
_scannerVersion = scannerVersion ?? throw new ArgumentNullException(nameof(scannerVersion));
|
_scannerVersion = scannerVersion ?? throw new ArgumentNullException(nameof(scannerVersion));
|
||||||
_metricsId = Guid.NewGuid();
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
|
_metricsId = _guidProvider.NewGuid();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -80,7 +87,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Start()
|
public void Start()
|
||||||
{
|
{
|
||||||
_startedAt = DateTimeOffset.UtcNow;
|
_startedAt = _timeProvider.GetUtcNow();
|
||||||
_totalStopwatch.Start();
|
_totalStopwatch.Start();
|
||||||
_logger.LogDebug("Started metrics collection for scan {ScanId}", _scanId);
|
_logger.LogDebug("Started metrics collection for scan {ScanId}", _scanId);
|
||||||
}
|
}
|
||||||
@@ -98,7 +105,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
|||||||
return NoOpDisposable.Instance;
|
return NoOpDisposable.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tracker = new PhaseTracker(this, phaseName, DateTimeOffset.UtcNow);
|
var tracker = new PhaseTracker(this, phaseName, _timeProvider.GetUtcNow());
|
||||||
_phases[phaseName] = tracker;
|
_phases[phaseName] = tracker;
|
||||||
_logger.LogDebug("Started phase {PhaseName} for scan {ScanId}", phaseName, _scanId);
|
_logger.LogDebug("Started phase {PhaseName} for scan {ScanId}", phaseName, _scanId);
|
||||||
return tracker;
|
return tracker;
|
||||||
@@ -138,7 +145,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
|||||||
|
|
||||||
_phases.Remove(phaseName);
|
_phases.Remove(phaseName);
|
||||||
|
|
||||||
var finishedAt = DateTimeOffset.UtcNow;
|
var finishedAt = _timeProvider.GetUtcNow();
|
||||||
var phase = new ExecutionPhase
|
var phase = new ExecutionPhase
|
||||||
{
|
{
|
||||||
MetricsId = _metricsId,
|
MetricsId = _metricsId,
|
||||||
@@ -214,7 +221,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
|||||||
public async Task CompleteAsync(CancellationToken cancellationToken = default)
|
public async Task CompleteAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_totalStopwatch.Stop();
|
_totalStopwatch.Stop();
|
||||||
var finishedAt = DateTimeOffset.UtcNow;
|
var finishedAt = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
// Calculate phase timings
|
// Calculate phase timings
|
||||||
var phases = BuildPhaseTimings();
|
var phases = BuildPhaseTimings();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Scanner.Explainability.Assumptions;
|
using StellaOps.Scanner.Explainability.Assumptions;
|
||||||
|
|
||||||
namespace StellaOps.Scanner.Explainability.Falsifiability;
|
namespace StellaOps.Scanner.Explainability.Falsifiability;
|
||||||
@@ -60,10 +61,17 @@ public interface IFalsifiabilityGenerator
|
|||||||
public sealed class FalsifiabilityGenerator : IFalsifiabilityGenerator
|
public sealed class FalsifiabilityGenerator : IFalsifiabilityGenerator
|
||||||
{
|
{
|
||||||
private readonly ILogger<FalsifiabilityGenerator> _logger;
|
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;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -164,12 +172,12 @@ public sealed class FalsifiabilityGenerator : IFalsifiabilityGenerator
|
|||||||
|
|
||||||
return new FalsifiabilityCriteria
|
return new FalsifiabilityCriteria
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = _guidProvider.NewGuid().ToString("N"),
|
||||||
FindingId = input.FindingId,
|
FindingId = input.FindingId,
|
||||||
Criteria = [.. criteria],
|
Criteria = [.. criteria],
|
||||||
Status = status,
|
Status = status,
|
||||||
Summary = summary,
|
Summary = summary,
|
||||||
GeneratedAt = DateTimeOffset.UtcNow
|
GeneratedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Copyright (c) StellaOps
|
// Copyright (c) StellaOps
|
||||||
|
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Scanner.Explainability.Assumptions;
|
using StellaOps.Scanner.Explainability.Assumptions;
|
||||||
using StellaOps.Scanner.Explainability.Confidence;
|
using StellaOps.Scanner.Explainability.Confidence;
|
||||||
using StellaOps.Scanner.Explainability.Falsifiability;
|
using StellaOps.Scanner.Explainability.Falsifiability;
|
||||||
@@ -118,10 +119,17 @@ public sealed class RiskReportGenerator : IRiskReportGenerator
|
|||||||
private const string EngineVersionValue = "1.0.0";
|
private const string EngineVersionValue = "1.0.0";
|
||||||
|
|
||||||
private readonly IEvidenceDensityScorer _scorer;
|
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;
|
_scorer = scorer;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -140,7 +148,7 @@ public sealed class RiskReportGenerator : IRiskReportGenerator
|
|||||||
|
|
||||||
return new RiskReport
|
return new RiskReport
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = _guidProvider.NewGuid().ToString("N"),
|
||||||
FindingId = input.FindingId,
|
FindingId = input.FindingId,
|
||||||
VulnerabilityId = input.VulnerabilityId,
|
VulnerabilityId = input.VulnerabilityId,
|
||||||
PackageName = input.PackageName,
|
PackageName = input.PackageName,
|
||||||
@@ -151,7 +159,7 @@ public sealed class RiskReportGenerator : IRiskReportGenerator
|
|||||||
Explanation = explanation,
|
Explanation = explanation,
|
||||||
DetailedNarrative = narrative,
|
DetailedNarrative = narrative,
|
||||||
RecommendedActions = [.. actions],
|
RecommendedActions = [.. actions],
|
||||||
GeneratedAt = DateTimeOffset.UtcNow,
|
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||||
EngineVersion = EngineVersionValue
|
EngineVersion = EngineVersionValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,8 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
|||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ICredentialResolver _credentialResolver;
|
private readonly ICredentialResolver _credentialResolver;
|
||||||
private readonly ILogger<DockerConnectionTester> _logger;
|
private readonly ILogger<DockerConnectionTester> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -28,11 +29,13 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
|||||||
public DockerConnectionTester(
|
public DockerConnectionTester(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
ICredentialResolver credentialResolver,
|
ICredentialResolver credentialResolver,
|
||||||
ILogger<DockerConnectionTester> logger)
|
ILogger<DockerConnectionTester> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_credentialResolver = credentialResolver;
|
_credentialResolver = credentialResolver;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ConnectionTestResult> TestAsync(
|
public async Task<ConnectionTestResult> TestAsync(
|
||||||
@@ -47,7 +50,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Invalid configuration format",
|
Message = "Invalid configuration format",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +103,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Registry accessible but image test failed: {imageTestResult.Message}",
|
Message = $"Registry accessible but image test failed: {imageTestResult.Message}",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,7 +115,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Successfully connected to Docker registry",
|
Message = "Successfully connected to Docker registry",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -125,21 +128,21 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Authentication required - configure credentials",
|
Message = "Authentication required - configure credentials",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
},
|
},
|
||||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Access denied - check permissions",
|
Message = "Access denied - check permissions",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
},
|
},
|
||||||
_ => new ConnectionTestResult
|
_ => new ConnectionTestResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Registry returned {response.StatusCode}",
|
Message = $"Registry returned {response.StatusCode}",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -151,7 +154,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Connection failed: {ex.Message}",
|
Message = $"Connection failed: {ex.Message}",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||||
@@ -160,7 +163,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Connection timed out",
|
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 IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ICredentialResolver _credentialResolver;
|
private readonly ICredentialResolver _credentialResolver;
|
||||||
private readonly ILogger<GitConnectionTester> _logger;
|
private readonly ILogger<GitConnectionTester> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -28,11 +29,13 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
|||||||
public GitConnectionTester(
|
public GitConnectionTester(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
ICredentialResolver credentialResolver,
|
ICredentialResolver credentialResolver,
|
||||||
ILogger<GitConnectionTester> logger)
|
ILogger<GitConnectionTester> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_credentialResolver = credentialResolver;
|
_credentialResolver = credentialResolver;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ConnectionTestResult> TestAsync(
|
public async Task<ConnectionTestResult> TestAsync(
|
||||||
@@ -47,7 +50,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Invalid configuration format",
|
Message = "Invalid configuration format",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +129,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Successfully connected to Git repository",
|
Message = "Successfully connected to Git repository",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -139,28 +142,28 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Authentication required - configure credentials",
|
Message = "Authentication required - configure credentials",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
},
|
},
|
||||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Access denied - check token permissions",
|
Message = "Access denied - check token permissions",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
},
|
},
|
||||||
HttpStatusCode.NotFound => new ConnectionTestResult
|
HttpStatusCode.NotFound => new ConnectionTestResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Repository not found - check URL and access",
|
Message = "Repository not found - check URL and access",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
},
|
},
|
||||||
_ => new ConnectionTestResult
|
_ => new ConnectionTestResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Server returned {response.StatusCode}",
|
Message = $"Server returned {response.StatusCode}",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -172,7 +175,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Connection failed: {ex.Message}",
|
Message = $"Connection failed: {ex.Message}",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["repositoryUrl"] = config.RepositoryUrl
|
["repositoryUrl"] = config.RepositoryUrl
|
||||||
@@ -185,7 +188,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Connection timed out",
|
Message = "Connection timed out",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +205,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "SSH configuration accepted - connection will be validated on first scan",
|
Message = "SSH configuration accepted - connection will be validated on first scan",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["repositoryUrl"] = config.RepositoryUrl,
|
["repositoryUrl"] = config.RepositoryUrl,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
|||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ICredentialResolver _credentialResolver;
|
private readonly ICredentialResolver _credentialResolver;
|
||||||
private readonly ILogger<ZastavaConnectionTester> _logger;
|
private readonly ILogger<ZastavaConnectionTester> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -29,11 +30,13 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
|||||||
public ZastavaConnectionTester(
|
public ZastavaConnectionTester(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
ICredentialResolver credentialResolver,
|
ICredentialResolver credentialResolver,
|
||||||
ILogger<ZastavaConnectionTester> logger)
|
ILogger<ZastavaConnectionTester> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_credentialResolver = credentialResolver;
|
_credentialResolver = credentialResolver;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ConnectionTestResult> TestAsync(
|
public async Task<ConnectionTestResult> TestAsync(
|
||||||
@@ -48,7 +51,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Invalid configuration format",
|
Message = "Invalid configuration format",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +93,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Successfully connected to registry",
|
Message = "Successfully connected to registry",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -104,28 +107,28 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Authentication failed - check credentials",
|
Message = "Authentication failed - check credentials",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
},
|
},
|
||||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Access denied - insufficient permissions",
|
Message = "Access denied - insufficient permissions",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
},
|
},
|
||||||
HttpStatusCode.NotFound => new ConnectionTestResult
|
HttpStatusCode.NotFound => new ConnectionTestResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Registry endpoint not found - check URL",
|
Message = "Registry endpoint not found - check URL",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
},
|
},
|
||||||
_ => new ConnectionTestResult
|
_ => new ConnectionTestResult
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Registry returned {response.StatusCode}",
|
Message = $"Registry returned {response.StatusCode}",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = details
|
Details = details
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -137,7 +140,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Connection failed: {ex.Message}",
|
Message = $"Connection failed: {ex.Message}",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["registryUrl"] = config.RegistryUrl,
|
["registryUrl"] = config.RegistryUrl,
|
||||||
@@ -151,7 +154,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Connection timed out",
|
Message = "Connection timed out",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["registryUrl"] = config.RegistryUrl,
|
["registryUrl"] = config.RegistryUrl,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
|||||||
{
|
{
|
||||||
private readonly ISourceConfigValidator _configValidator;
|
private readonly ISourceConfigValidator _configValidator;
|
||||||
private readonly ILogger<CliSourceHandler> _logger;
|
private readonly ILogger<CliSourceHandler> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -38,10 +39,12 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
|||||||
|
|
||||||
public CliSourceHandler(
|
public CliSourceHandler(
|
||||||
ISourceConfigValidator configValidator,
|
ISourceConfigValidator configValidator,
|
||||||
ILogger<CliSourceHandler> logger)
|
ILogger<CliSourceHandler> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_configValidator = configValidator;
|
_configValidator = configValidator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -102,7 +105,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Invalid configuration",
|
Message = "Invalid configuration",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "CLI source configuration is valid",
|
Message = "CLI source configuration is valid",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["allowedTools"] = config.AllowedTools,
|
["allowedTools"] = config.AllowedTools,
|
||||||
@@ -242,8 +245,8 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
|||||||
Token = token,
|
Token = token,
|
||||||
TokenHash = Convert.ToHexString(tokenHash).ToLowerInvariant(),
|
TokenHash = Convert.ToHexString(tokenHash).ToLowerInvariant(),
|
||||||
SourceId = source.SourceId,
|
SourceId = source.SourceId,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.Add(validity),
|
ExpiresAt = _timeProvider.GetUtcNow().Add(validity),
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
CreatedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
|||||||
private readonly ISourceConfigValidator _configValidator;
|
private readonly ISourceConfigValidator _configValidator;
|
||||||
private readonly IImageDiscoveryService _discoveryService;
|
private readonly IImageDiscoveryService _discoveryService;
|
||||||
private readonly ILogger<DockerSourceHandler> _logger;
|
private readonly ILogger<DockerSourceHandler> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -38,13 +39,15 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
|||||||
ICredentialResolver credentialResolver,
|
ICredentialResolver credentialResolver,
|
||||||
ISourceConfigValidator configValidator,
|
ISourceConfigValidator configValidator,
|
||||||
IImageDiscoveryService discoveryService,
|
IImageDiscoveryService discoveryService,
|
||||||
ILogger<DockerSourceHandler> logger)
|
ILogger<DockerSourceHandler> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_clientFactory = clientFactory;
|
_clientFactory = clientFactory;
|
||||||
_credentialResolver = credentialResolver;
|
_credentialResolver = credentialResolver;
|
||||||
_configValidator = configValidator;
|
_configValidator = configValidator;
|
||||||
_discoveryService = discoveryService;
|
_discoveryService = discoveryService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||||
@@ -136,7 +139,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
|||||||
// Apply age filter if specified
|
// Apply age filter if specified
|
||||||
if (imageSpec.MaxAgeHours.HasValue)
|
if (imageSpec.MaxAgeHours.HasValue)
|
||||||
{
|
{
|
||||||
var cutoff = DateTimeOffset.UtcNow.AddHours(-imageSpec.MaxAgeHours.Value);
|
var cutoff = _timeProvider.GetUtcNow().AddHours(-imageSpec.MaxAgeHours.Value);
|
||||||
sortedTags = sortedTags
|
sortedTags = sortedTags
|
||||||
.Where(t => t.LastUpdated == null || t.LastUpdated >= cutoff)
|
.Where(t => t.LastUpdated == null || t.LastUpdated >= cutoff)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -181,7 +184,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Invalid configuration",
|
Message = "Invalid configuration",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +201,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Registry ping failed",
|
Message = "Registry ping failed",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["registryUrl"] = config.RegistryUrl
|
["registryUrl"] = config.RegistryUrl
|
||||||
@@ -216,7 +219,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Successfully connected to registry",
|
Message = "Successfully connected to registry",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["registryUrl"] = config.RegistryUrl,
|
["registryUrl"] = config.RegistryUrl,
|
||||||
@@ -230,7 +233,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Successfully connected to registry",
|
Message = "Successfully connected to registry",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["registryUrl"] = config.RegistryUrl
|
["registryUrl"] = config.RegistryUrl
|
||||||
@@ -244,7 +247,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Connection failed: {ex.Message}",
|
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 ICredentialResolver _credentialResolver;
|
||||||
private readonly ISourceConfigValidator _configValidator;
|
private readonly ISourceConfigValidator _configValidator;
|
||||||
private readonly ILogger<GitSourceHandler> _logger;
|
private readonly ILogger<GitSourceHandler> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -35,12 +36,14 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
|||||||
IGitClientFactory gitClientFactory,
|
IGitClientFactory gitClientFactory,
|
||||||
ICredentialResolver credentialResolver,
|
ICredentialResolver credentialResolver,
|
||||||
ISourceConfigValidator configValidator,
|
ISourceConfigValidator configValidator,
|
||||||
ILogger<GitSourceHandler> logger)
|
ILogger<GitSourceHandler> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_gitClientFactory = gitClientFactory;
|
_gitClientFactory = gitClientFactory;
|
||||||
_credentialResolver = credentialResolver;
|
_credentialResolver = credentialResolver;
|
||||||
_configValidator = configValidator;
|
_configValidator = configValidator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||||
@@ -160,7 +163,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Invalid configuration",
|
Message = "Invalid configuration",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +179,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Repository not found or inaccessible",
|
Message = "Repository not found or inaccessible",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["repositoryUrl"] = config.RepositoryUrl,
|
["repositoryUrl"] = config.RepositoryUrl,
|
||||||
@@ -189,7 +192,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Successfully connected to repository",
|
Message = "Successfully connected to repository",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["repositoryUrl"] = config.RepositoryUrl,
|
["repositoryUrl"] = config.RepositoryUrl,
|
||||||
@@ -206,7 +209,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Connection failed: {ex.Message}",
|
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)
|
sender.TryGetProperty("login", out var login)
|
||||||
? login.GetString()
|
? login.GetString()
|
||||||
: null,
|
: null,
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +306,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
|||||||
? num.GetInt32().ToString()
|
? 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)
|
Actor = root.TryGetProperty("user_name", out var userName)
|
||||||
? userName.GetString()
|
? userName.GetString()
|
||||||
: null,
|
: null,
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +364,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
|||||||
? mrAction.GetString() ?? ""
|
? mrAction.GetString() ?? ""
|
||||||
: ""
|
: ""
|
||||||
},
|
},
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,7 +374,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
|||||||
{
|
{
|
||||||
EventType = "unknown",
|
EventType = "unknown",
|
||||||
Reference = "",
|
Reference = "",
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
|||||||
private readonly ICredentialResolver _credentialResolver;
|
private readonly ICredentialResolver _credentialResolver;
|
||||||
private readonly ISourceConfigValidator _configValidator;
|
private readonly ISourceConfigValidator _configValidator;
|
||||||
private readonly ILogger<ZastavaSourceHandler> _logger;
|
private readonly ILogger<ZastavaSourceHandler> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -36,12 +37,14 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
|||||||
IRegistryClientFactory clientFactory,
|
IRegistryClientFactory clientFactory,
|
||||||
ICredentialResolver credentialResolver,
|
ICredentialResolver credentialResolver,
|
||||||
ISourceConfigValidator configValidator,
|
ISourceConfigValidator configValidator,
|
||||||
ILogger<ZastavaSourceHandler> logger)
|
ILogger<ZastavaSourceHandler> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_clientFactory = clientFactory;
|
_clientFactory = clientFactory;
|
||||||
_credentialResolver = credentialResolver;
|
_credentialResolver = credentialResolver;
|
||||||
_configValidator = configValidator;
|
_configValidator = configValidator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||||
@@ -167,7 +170,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Invalid configuration",
|
Message = "Invalid configuration",
|
||||||
TestedAt = DateTimeOffset.UtcNow
|
TestedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +186,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = "Registry ping failed",
|
Message = "Registry ping failed",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["registryUrl"] = config.RegistryUrl,
|
["registryUrl"] = config.RegistryUrl,
|
||||||
@@ -199,7 +202,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
|||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Message = "Successfully connected to registry",
|
Message = "Successfully connected to registry",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["registryUrl"] = config.RegistryUrl,
|
["registryUrl"] = config.RegistryUrl,
|
||||||
@@ -215,7 +218,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"Connection failed: {ex.Message}",
|
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()!,
|
: repository.GetProperty("name").GetString()!,
|
||||||
Tag = pushData.TryGetProperty("tag", out var tag) ? tag.GetString() : "latest",
|
Tag = pushData.TryGetProperty("tag", out var tag) ? tag.GetString() : "latest",
|
||||||
Actor = pushData.TryGetProperty("pusher", out var pusher) ? pusher.GetString() : null,
|
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()
|
? digest.GetString()
|
||||||
: null,
|
: null,
|
||||||
Actor = eventData.TryGetProperty("operator", out var op) ? op.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)
|
actor.TryGetProperty("name", out var actorName)
|
||||||
? actorName.GetString()
|
? actorName.GetString()
|
||||||
: null,
|
: null,
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +350,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
|||||||
{
|
{
|
||||||
EventType = "unknown",
|
EventType = "unknown",
|
||||||
Reference = "",
|
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 Schema = "scanner";
|
||||||
private const string Table = "sbom_sources";
|
private const string Table = "sbom_sources";
|
||||||
private const string FullTable = $"{Schema}.{Table}";
|
private const string FullTable = $"{Schema}.{Table}";
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public SbomSourceRepository(
|
public SbomSourceRepository(
|
||||||
ScannerSourcesDataSource dataSource,
|
ScannerSourcesDataSource dataSource,
|
||||||
ILogger<SbomSourceRepository> logger)
|
ILogger<SbomSourceRepository> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
: base(dataSource, logger)
|
: base(dataSource, logger)
|
||||||
{
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
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)
|
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)
|
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 Schema = "scanner";
|
||||||
private const string Table = "sbom_source_runs";
|
private const string Table = "sbom_source_runs";
|
||||||
private const string FullTable = $"{Schema}.{Table}";
|
private const string FullTable = $"{Schema}.{Table}";
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public SbomSourceRunRepository(
|
public SbomSourceRunRepository(
|
||||||
ScannerSourcesDataSource dataSource,
|
ScannerSourcesDataSource dataSource,
|
||||||
ILogger<SbomSourceRunRepository> logger)
|
ILogger<SbomSourceRunRepository> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
: base(dataSource, logger)
|
: base(dataSource, logger)
|
||||||
{
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default)
|
public async Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default)
|
||||||
@@ -188,7 +191,7 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
|
|||||||
sql,
|
sql,
|
||||||
cmd =>
|
cmd =>
|
||||||
{
|
{
|
||||||
AddParameter(cmd, "threshold", DateTimeOffset.UtcNow - olderThan);
|
AddParameter(cmd, "threshold", _timeProvider.GetUtcNow() - olderThan);
|
||||||
AddParameter(cmd, "limit", limit);
|
AddParameter(cmd, "limit", limit);
|
||||||
},
|
},
|
||||||
MapRun,
|
MapRun,
|
||||||
|
|||||||
@@ -17,19 +17,22 @@ public sealed class SbomSourceService : ISbomSourceService
|
|||||||
private readonly ISourceConfigValidator _configValidator;
|
private readonly ISourceConfigValidator _configValidator;
|
||||||
private readonly ISourceConnectionTester _connectionTester;
|
private readonly ISourceConnectionTester _connectionTester;
|
||||||
private readonly ILogger<SbomSourceService> _logger;
|
private readonly ILogger<SbomSourceService> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public SbomSourceService(
|
public SbomSourceService(
|
||||||
ISbomSourceRepository sourceRepository,
|
ISbomSourceRepository sourceRepository,
|
||||||
ISbomSourceRunRepository runRepository,
|
ISbomSourceRunRepository runRepository,
|
||||||
ISourceConfigValidator configValidator,
|
ISourceConfigValidator configValidator,
|
||||||
ISourceConnectionTester connectionTester,
|
ISourceConnectionTester connectionTester,
|
||||||
ILogger<SbomSourceService> logger)
|
ILogger<SbomSourceService> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_sourceRepository = sourceRepository;
|
_sourceRepository = sourceRepository;
|
||||||
_runRepository = runRepository;
|
_runRepository = runRepository;
|
||||||
_configValidator = configValidator;
|
_configValidator = configValidator;
|
||||||
_connectionTester = connectionTester;
|
_connectionTester = connectionTester;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
public async Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||||
@@ -215,7 +218,7 @@ public sealed class SbomSourceService : ISbomSourceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Touch updated fields
|
// Touch updated fields
|
||||||
SetProperty(source, "UpdatedAt", DateTimeOffset.UtcNow);
|
SetProperty(source, "UpdatedAt", _timeProvider.GetUtcNow());
|
||||||
SetProperty(source, "UpdatedBy", updatedBy);
|
SetProperty(source, "UpdatedBy", updatedBy);
|
||||||
|
|
||||||
await _sourceRepository.UpdateAsync(source, ct);
|
await _sourceRepository.UpdateAsync(source, ct);
|
||||||
|
|||||||
@@ -12,13 +12,16 @@ public sealed class SourceConnectionTester : ISourceConnectionTester
|
|||||||
{
|
{
|
||||||
private readonly IEnumerable<ISourceTypeConnectionTester> _testers;
|
private readonly IEnumerable<ISourceTypeConnectionTester> _testers;
|
||||||
private readonly ILogger<SourceConnectionTester> _logger;
|
private readonly ILogger<SourceConnectionTester> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public SourceConnectionTester(
|
public SourceConnectionTester(
|
||||||
IEnumerable<ISourceTypeConnectionTester> testers,
|
IEnumerable<ISourceTypeConnectionTester> testers,
|
||||||
ILogger<SourceConnectionTester> logger)
|
ILogger<SourceConnectionTester> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_testers = testers;
|
_testers = testers;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ConnectionTestResult> TestAsync(SbomSource source, CancellationToken ct = default)
|
public Task<ConnectionTestResult> TestAsync(SbomSource source, CancellationToken ct = default)
|
||||||
@@ -42,7 +45,7 @@ public sealed class SourceConnectionTester : ISourceConnectionTester
|
|||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
Message = $"No connection tester available for source type {source.SourceType}",
|
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,
|
Success = false,
|
||||||
Message = $"Connection test error: {ex.Message}",
|
Message = $"Connection test error: {ex.Message}",
|
||||||
TestedAt = DateTimeOffset.UtcNow,
|
TestedAt = _timeProvider.GetUtcNow(),
|
||||||
Details = new Dictionary<string, object>
|
Details = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["exceptionType"] = ex.GetType().Name
|
["exceptionType"] = ex.GetType().Name
|
||||||
|
|||||||
@@ -22,5 +22,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Scanner.Sources.Domain;
|
using StellaOps.Scanner.Sources.Domain;
|
||||||
using StellaOps.Scanner.Sources.Handlers;
|
using StellaOps.Scanner.Sources.Handlers;
|
||||||
using StellaOps.Scanner.Sources.Persistence;
|
using StellaOps.Scanner.Sources.Persistence;
|
||||||
@@ -15,19 +16,25 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
|||||||
private readonly IEnumerable<ISourceTypeHandler> _handlers;
|
private readonly IEnumerable<ISourceTypeHandler> _handlers;
|
||||||
private readonly IScanJobQueue _scanJobQueue;
|
private readonly IScanJobQueue _scanJobQueue;
|
||||||
private readonly ILogger<SourceTriggerDispatcher> _logger;
|
private readonly ILogger<SourceTriggerDispatcher> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public SourceTriggerDispatcher(
|
public SourceTriggerDispatcher(
|
||||||
ISbomSourceRepository sourceRepository,
|
ISbomSourceRepository sourceRepository,
|
||||||
ISbomSourceRunRepository runRepository,
|
ISbomSourceRunRepository runRepository,
|
||||||
IEnumerable<ISourceTypeHandler> handlers,
|
IEnumerable<ISourceTypeHandler> handlers,
|
||||||
IScanJobQueue scanJobQueue,
|
IScanJobQueue scanJobQueue,
|
||||||
ILogger<SourceTriggerDispatcher> logger)
|
ILogger<SourceTriggerDispatcher> logger,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_sourceRepository = sourceRepository;
|
_sourceRepository = sourceRepository;
|
||||||
_runRepository = runRepository;
|
_runRepository = runRepository;
|
||||||
_handlers = handlers;
|
_handlers = handlers;
|
||||||
_scanJobQueue = scanJobQueue;
|
_scanJobQueue = scanJobQueue;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<TriggerDispatchResult> DispatchAsync(
|
public Task<TriggerDispatchResult> DispatchAsync(
|
||||||
@@ -40,7 +47,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
|||||||
{
|
{
|
||||||
Trigger = trigger,
|
Trigger = trigger,
|
||||||
TriggerDetails = triggerDetails,
|
TriggerDetails = triggerDetails,
|
||||||
CorrelationId = Guid.NewGuid().ToString("N")
|
CorrelationId = _guidProvider.NewGuid().ToString("N")
|
||||||
};
|
};
|
||||||
|
|
||||||
return DispatchAsync(sourceId, context, ct);
|
return DispatchAsync(sourceId, context, ct);
|
||||||
@@ -128,7 +135,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
|||||||
{
|
{
|
||||||
run.Complete();
|
run.Complete();
|
||||||
await _runRepository.UpdateAsync(run, ct);
|
await _runRepository.UpdateAsync(run, ct);
|
||||||
source.RecordSuccessfulRun(DateTimeOffset.UtcNow);
|
source.RecordSuccessfulRun(_timeProvider.GetUtcNow());
|
||||||
await _sourceRepository.UpdateAsync(source, ct);
|
await _sourceRepository.UpdateAsync(source, ct);
|
||||||
|
|
||||||
return new TriggerDispatchResult
|
return new TriggerDispatchResult
|
||||||
@@ -170,12 +177,12 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
|||||||
if (run.ItemsFailed == run.ItemsDiscovered)
|
if (run.ItemsFailed == run.ItemsDiscovered)
|
||||||
{
|
{
|
||||||
run.Fail("All targets failed to queue");
|
run.Fail("All targets failed to queue");
|
||||||
source.RecordFailedRun(DateTimeOffset.UtcNow, run.ErrorMessage!);
|
source.RecordFailedRun(_timeProvider.GetUtcNow(), run.ErrorMessage!);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
run.Complete();
|
run.Complete();
|
||||||
source.RecordSuccessfulRun(DateTimeOffset.UtcNow);
|
source.RecordSuccessfulRun(_timeProvider.GetUtcNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
await _runRepository.UpdateAsync(run, ct);
|
await _runRepository.UpdateAsync(run, ct);
|
||||||
@@ -195,7 +202,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
|||||||
run.Fail(ex.Message);
|
run.Fail(ex.Message);
|
||||||
await _runRepository.UpdateAsync(run, ct);
|
await _runRepository.UpdateAsync(run, ct);
|
||||||
|
|
||||||
source.RecordFailedRun(DateTimeOffset.UtcNow, ex.Message);
|
source.RecordFailedRun(_timeProvider.GetUtcNow(), ex.Message);
|
||||||
await _sourceRepository.UpdateAsync(source, ct);
|
await _sourceRepository.UpdateAsync(source, ct);
|
||||||
|
|
||||||
return new TriggerDispatchResult
|
return new TriggerDispatchResult
|
||||||
@@ -247,7 +254,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
|||||||
{
|
{
|
||||||
Trigger = originalRun.Trigger,
|
Trigger = originalRun.Trigger,
|
||||||
TriggerDetails = $"Retry of run {originalRunId}",
|
TriggerDetails = $"Retry of run {originalRunId}",
|
||||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
CorrelationId = _guidProvider.NewGuid().ToString("N"),
|
||||||
Metadata = new() { ["originalRunId"] = originalRunId.ToString() }
|
Metadata = new() { ["originalRunId"] = originalRunId.ToString() }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ public sealed class SlicePullService : IDisposable
|
|||||||
private readonly OciRegistryAuthorization _authorization;
|
private readonly OciRegistryAuthorization _authorization;
|
||||||
private readonly SlicePullOptions _options;
|
private readonly SlicePullOptions _options;
|
||||||
private readonly ILogger<SlicePullService> _logger;
|
private readonly ILogger<SlicePullService> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly Dictionary<string, CachedSlice> _cache = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, CachedSlice> _cache = new(StringComparer.Ordinal);
|
||||||
private readonly Lock _cacheLock = new();
|
private readonly Lock _cacheLock = new();
|
||||||
|
|
||||||
@@ -70,12 +71,14 @@ public sealed class SlicePullService : IDisposable
|
|||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
OciRegistryAuthorization authorization,
|
OciRegistryAuthorization authorization,
|
||||||
SlicePullOptions? options = null,
|
SlicePullOptions? options = null,
|
||||||
ILogger<SlicePullService>? logger = null)
|
ILogger<SlicePullService>? logger = null,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
_authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
|
_authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
|
||||||
_options = options ?? new SlicePullOptions();
|
_options = options ?? new SlicePullOptions();
|
||||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SlicePullService>.Instance;
|
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SlicePullService>.Instance;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
_httpClient.Timeout = _options.RequestTimeout;
|
_httpClient.Timeout = _options.RequestTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +214,7 @@ public sealed class SlicePullService : IDisposable
|
|||||||
var dsseLayer = manifest.Layers?.FirstOrDefault(l =>
|
var dsseLayer = manifest.Layers?.FirstOrDefault(l =>
|
||||||
l.MediaType == OciMediaTypes.DsseEnvelope);
|
l.MediaType == OciMediaTypes.DsseEnvelope);
|
||||||
|
|
||||||
if (dsseLayer != null && _options.VerifySignature)
|
if (dsseLayer?.Digest != null && _options.VerifySignature)
|
||||||
{
|
{
|
||||||
var dsseResult = await FetchAndVerifyDsseAsync(reference, dsseLayer.Digest, sliceBytes, cancellationToken)
|
var dsseResult = await FetchAndVerifyDsseAsync(reference, dsseLayer.Digest, sliceBytes, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
@@ -227,7 +230,7 @@ public sealed class SlicePullService : IDisposable
|
|||||||
SliceData = sliceData,
|
SliceData = sliceData,
|
||||||
DsseEnvelope = dsseEnvelope,
|
DsseEnvelope = dsseEnvelope,
|
||||||
SignatureVerified = signatureVerified,
|
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 (_cache.TryGetValue(key, out cached))
|
||||||
{
|
{
|
||||||
if (cached.ExpiresAt > DateTimeOffset.UtcNow)
|
if (cached.ExpiresAt > _timeProvider.GetUtcNow())
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using NpgsqlTypes;
|
using NpgsqlTypes;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Scanner.Storage.Entities;
|
using StellaOps.Scanner.Storage.Entities;
|
||||||
|
|
||||||
namespace StellaOps.Scanner.Storage.Postgres;
|
namespace StellaOps.Scanner.Storage.Postgres;
|
||||||
@@ -64,10 +65,17 @@ public interface IFuncProofRepository
|
|||||||
public sealed class PostgresFuncProofRepository : IFuncProofRepository
|
public sealed class PostgresFuncProofRepository : IFuncProofRepository
|
||||||
{
|
{
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
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));
|
_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)
|
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 conn = await _dataSource.OpenConnectionAsync(ct);
|
||||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
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("id", id);
|
||||||
cmd.Parameters.AddWithValue("scan_id", document.ScanId);
|
cmd.Parameters.AddWithValue("scan_id", document.ScanId);
|
||||||
@@ -118,7 +126,7 @@ public sealed class PostgresFuncProofRepository : IFuncProofRepository
|
|||||||
document.RekorEntryId is null ? DBNull.Value : document.RekorEntryId);
|
document.RekorEntryId is null ? DBNull.Value : document.RekorEntryId);
|
||||||
cmd.Parameters.AddWithValue("generator_version", document.GeneratorVersion);
|
cmd.Parameters.AddWithValue("generator_version", document.GeneratorVersion);
|
||||||
cmd.Parameters.AddWithValue("generated_at_utc", document.GeneratedAtUtc);
|
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);
|
var result = await cmd.ExecuteScalarAsync(ct);
|
||||||
return result is Guid returnedId ? returnedId : id;
|
return result is Guid returnedId ? returnedId : id;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Scanner.Storage.Entities;
|
using StellaOps.Scanner.Storage.Entities;
|
||||||
using StellaOps.Scanner.Storage.Repositories;
|
using StellaOps.Scanner.Storage.Repositories;
|
||||||
|
|
||||||
@@ -20,14 +21,17 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
|||||||
{
|
{
|
||||||
private readonly ScannerDataSource _dataSource;
|
private readonly ScannerDataSource _dataSource;
|
||||||
private readonly ILogger<PostgresIdempotencyKeyRepository> _logger;
|
private readonly ILogger<PostgresIdempotencyKeyRepository> _logger;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||||
|
|
||||||
public PostgresIdempotencyKeyRepository(
|
public PostgresIdempotencyKeyRepository(
|
||||||
ScannerDataSource dataSource,
|
ScannerDataSource dataSource,
|
||||||
ILogger<PostgresIdempotencyKeyRepository> logger)
|
ILogger<PostgresIdempotencyKeyRepository> logger,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -68,7 +72,7 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
|||||||
{
|
{
|
||||||
if (key.KeyId == Guid.Empty)
|
if (key.KeyId == Guid.Empty)
|
||||||
{
|
{
|
||||||
key.KeyId = Guid.NewGuid();
|
key.KeyId = _guidProvider.NewGuid();
|
||||||
}
|
}
|
||||||
|
|
||||||
var sql = $"""
|
var sql = $"""
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||||
using StellaOps.Replay.Core;
|
using StellaOps.Replay.Core;
|
||||||
using StellaOps.Scanner.ProofSpine;
|
using StellaOps.Scanner.ProofSpine;
|
||||||
@@ -28,14 +29,17 @@ public sealed class PostgresProofSpineRepository : RepositoryBase<ScannerDataSou
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public PostgresProofSpineRepository(
|
public PostgresProofSpineRepository(
|
||||||
ScannerDataSource dataSource,
|
ScannerDataSource dataSource,
|
||||||
ILogger<PostgresProofSpineRepository> logger,
|
ILogger<PostgresProofSpineRepository> logger,
|
||||||
TimeProvider? timeProvider = null)
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
: base(dataSource, logger)
|
: base(dataSource, logger)
|
||||||
{
|
{
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ProofSpineModel?> GetByIdAsync(string spineId, CancellationToken cancellationToken = default)
|
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))
|
await using (var command = CreateCommand(insertHistory, connection))
|
||||||
{
|
{
|
||||||
command.Transaction = transaction;
|
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, "old_spine_id", oldSpineId.Trim());
|
||||||
AddParameter(command, "new_spine_id", newSpineId.Trim());
|
AddParameter(command, "new_spine_id", newSpineId.Trim());
|
||||||
AddParameter(command, "reason", reason);
|
AddParameter(command, "reason", reason);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Scanner.Storage.Models;
|
using StellaOps.Scanner.Storage.Models;
|
||||||
|
|
||||||
namespace StellaOps.Scanner.Storage.Repositories;
|
namespace StellaOps.Scanner.Storage.Repositories;
|
||||||
@@ -19,13 +20,16 @@ public sealed class PostgresScanMetricsRepository : IScanMetricsRepository
|
|||||||
{
|
{
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
private readonly ILogger<PostgresScanMetricsRepository> _logger;
|
private readonly ILogger<PostgresScanMetricsRepository> _logger;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public PostgresScanMetricsRepository(
|
public PostgresScanMetricsRepository(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ILogger<PostgresScanMetricsRepository> logger)
|
ILogger<PostgresScanMetricsRepository> logger,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -67,7 +71,7 @@ public sealed class PostgresScanMetricsRepository : IScanMetricsRepository
|
|||||||
|
|
||||||
await using var cmd = _dataSource.CreateCommand(sql);
|
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("metricsId", metricsId);
|
||||||
cmd.Parameters.AddWithValue("scanId", metrics.ScanId);
|
cmd.Parameters.AddWithValue("scanId", metrics.ScanId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||||
using StellaOps.Scanner.Storage.Catalog;
|
using StellaOps.Scanner.Storage.Catalog;
|
||||||
using StellaOps.Scanner.Storage.Postgres;
|
using StellaOps.Scanner.Storage.Postgres;
|
||||||
@@ -16,10 +17,15 @@ public sealed class RuntimeEventRepository : RepositoryBase<ScannerDataSource>
|
|||||||
private string Table => $"{SchemaName}.runtime_events";
|
private string Table => $"{SchemaName}.runtime_events";
|
||||||
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
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)
|
: base(dataSource, logger)
|
||||||
{
|
{
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RuntimeEventInsertResult> InsertAsync(
|
public async Task<RuntimeEventInsertResult> InsertAsync(
|
||||||
@@ -52,7 +58,7 @@ public sealed class RuntimeEventRepository : RepositoryBase<ScannerDataSource>
|
|||||||
foreach (var document in documents)
|
foreach (var document in documents)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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(
|
var rows = await ExecuteAsync(
|
||||||
Tenant,
|
Tenant,
|
||||||
|
|||||||
@@ -28,5 +28,6 @@
|
|||||||
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
|
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
|
||||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
||||||
<ProjectReference Include="..\\..\\..\\Router\\__Libraries\\StellaOps.Messaging\\StellaOps.Messaging.csproj" />
|
<ProjectReference Include="..\\..\\..\\Router\\__Libraries\\StellaOps.Messaging\\StellaOps.Messaging.csproj" />
|
||||||
|
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Determinism.Abstractions\\StellaOps.Determinism.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -21,5 +21,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
||||||
|
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||||
|
|
||||||
namespace StellaOps.Scanner.VulnSurfaces.Storage;
|
namespace StellaOps.Scanner.VulnSurfaces.Storage;
|
||||||
@@ -18,15 +19,18 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
|
|||||||
{
|
{
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
private readonly NpgsqlDataSource _dataSource;
|
||||||
private readonly ILogger<PostgresVulnSurfaceRepository> _logger;
|
private readonly ILogger<PostgresVulnSurfaceRepository> _logger;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
private readonly int _commandTimeoutSeconds;
|
private readonly int _commandTimeoutSeconds;
|
||||||
|
|
||||||
public PostgresVulnSurfaceRepository(
|
public PostgresVulnSurfaceRepository(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ILogger<PostgresVulnSurfaceRepository> logger,
|
ILogger<PostgresVulnSurfaceRepository> logger,
|
||||||
|
IGuidProvider? guidProvider = null,
|
||||||
int commandTimeoutSeconds = 30)
|
int commandTimeoutSeconds = 30)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
_commandTimeoutSeconds = commandTimeoutSeconds;
|
_commandTimeoutSeconds = commandTimeoutSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +49,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
|
|||||||
string? attestationDigest,
|
string? attestationDigest,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
var id = _guidProvider.NewGuid();
|
||||||
|
|
||||||
const string sql = """
|
const string sql = """
|
||||||
INSERT INTO scanner.vuln_surfaces (
|
INSERT INTO scanner.vuln_surfaces (
|
||||||
@@ -106,7 +110,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
|
|||||||
string? fixedHash,
|
string? fixedHash,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
var id = _guidProvider.NewGuid();
|
||||||
|
|
||||||
const string sql = """
|
const string sql = """
|
||||||
INSERT INTO scanner.vuln_surface_sinks (
|
INSERT INTO scanner.vuln_surface_sinks (
|
||||||
@@ -148,7 +152,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
|
|||||||
double confidence,
|
double confidence,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid();
|
var id = _guidProvider.NewGuid();
|
||||||
|
|
||||||
const string sql = """
|
const string sql = """
|
||||||
INSERT INTO scanner.vuln_surface_triggers (
|
INSERT INTO scanner.vuln_surface_triggers (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Signer.Core;
|
using StellaOps.Signer.Core;
|
||||||
|
|
||||||
namespace StellaOps.Signer.Infrastructure.Auditing;
|
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 ConcurrentDictionary<string, SignerAuditEntry> _entries = new(StringComparer.Ordinal);
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
private readonly ILogger<InMemorySignerAuditSink> _logger;
|
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;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +36,7 @@ public sealed class InMemorySignerAuditSink : ISignerAuditSink
|
|||||||
ArgumentNullException.ThrowIfNull(entitlement);
|
ArgumentNullException.ThrowIfNull(entitlement);
|
||||||
ArgumentNullException.ThrowIfNull(caller);
|
ArgumentNullException.ThrowIfNull(caller);
|
||||||
|
|
||||||
var auditId = Guid.NewGuid().ToString("d");
|
var auditId = _guidProvider.NewGuid().ToString("d");
|
||||||
var entry = new SignerAuditEntry(
|
var entry = new SignerAuditEntry(
|
||||||
auditId,
|
auditId,
|
||||||
_timeProvider.GetUtcNow(),
|
_timeProvider.GetUtcNow(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Signer.Core;
|
using StellaOps.Signer.Core;
|
||||||
|
|
||||||
namespace StellaOps.Signer.Infrastructure.Signing;
|
namespace StellaOps.Signer.Infrastructure.Signing;
|
||||||
@@ -17,15 +18,18 @@ public sealed class DefaultSigningKeyResolver : ISigningKeyResolver
|
|||||||
|
|
||||||
private readonly DsseSignerOptions _options;
|
private readonly DsseSignerOptions _options;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
private readonly ILogger<DefaultSigningKeyResolver> _logger;
|
private readonly ILogger<DefaultSigningKeyResolver> _logger;
|
||||||
|
|
||||||
public DefaultSigningKeyResolver(
|
public DefaultSigningKeyResolver(
|
||||||
IOptions<DsseSignerOptions> options,
|
IOptions<DsseSignerOptions> options,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
ILogger<DefaultSigningKeyResolver> logger)
|
ILogger<DefaultSigningKeyResolver> logger,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +60,7 @@ public sealed class DefaultSigningKeyResolver : ISigningKeyResolver
|
|||||||
{
|
{
|
||||||
// Generate ephemeral key identifier using timestamp for uniqueness
|
// Generate ephemeral key identifier using timestamp for uniqueness
|
||||||
var now = _timeProvider.GetUtcNow();
|
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);
|
var expiresAt = now.AddMinutes(KeylessExpiryMinutes);
|
||||||
|
|
||||||
return new SigningKeyResolution(
|
return new SigningKeyResolution(
|
||||||
|
|||||||
@@ -18,17 +18,20 @@ public sealed class SigstoreSigningService : ISigstoreSigningService
|
|||||||
private readonly IFulcioClient _fulcioClient;
|
private readonly IFulcioClient _fulcioClient;
|
||||||
private readonly IRekorClient _rekorClient;
|
private readonly IRekorClient _rekorClient;
|
||||||
private readonly SigstoreOptions _options;
|
private readonly SigstoreOptions _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly ILogger<SigstoreSigningService> _logger;
|
private readonly ILogger<SigstoreSigningService> _logger;
|
||||||
|
|
||||||
public SigstoreSigningService(
|
public SigstoreSigningService(
|
||||||
IFulcioClient fulcioClient,
|
IFulcioClient fulcioClient,
|
||||||
IRekorClient rekorClient,
|
IRekorClient rekorClient,
|
||||||
IOptions<SigstoreOptions> options,
|
IOptions<SigstoreOptions> options,
|
||||||
ILogger<SigstoreSigningService> logger)
|
ILogger<SigstoreSigningService> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_fulcioClient = fulcioClient ?? throw new ArgumentNullException(nameof(fulcioClient));
|
_fulcioClient = fulcioClient ?? throw new ArgumentNullException(nameof(fulcioClient));
|
||||||
_rekorClient = rekorClient ?? throw new ArgumentNullException(nameof(rekorClient));
|
_rekorClient = rekorClient ?? throw new ArgumentNullException(nameof(rekorClient));
|
||||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +136,7 @@ public sealed class SigstoreSigningService : ISigstoreSigningService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check certificate validity
|
// 3. Check certificate validity
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
if (now < cert.NotBefore || now > cert.NotAfter)
|
if (now < cert.NotBefore || now > cert.NotAfter)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ public static class KeyRotationEndpoints
|
|||||||
[FromBody] RevokeKeyRequestDto request,
|
[FromBody] RevokeKeyRequestDto request,
|
||||||
IKeyRotationService rotationService,
|
IKeyRotationService rotationService,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
|
TimeProvider timeProvider,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var logger = loggerFactory.CreateLogger("KeyRotationEndpoints.RevokeKey");
|
var logger = loggerFactory.CreateLogger("KeyRotationEndpoints.RevokeKey");
|
||||||
@@ -183,7 +184,7 @@ public static class KeyRotationEndpoints
|
|||||||
{
|
{
|
||||||
KeyId = keyId,
|
KeyId = keyId,
|
||||||
AnchorId = anchorId,
|
AnchorId = anchorId,
|
||||||
RevokedAt = request.EffectiveAt ?? DateTimeOffset.UtcNow,
|
RevokedAt = request.EffectiveAt ?? timeProvider.GetUtcNow(),
|
||||||
Reason = request.Reason,
|
Reason = request.Reason,
|
||||||
AllowedKeyIds = result.AllowedKeyIds.ToList(),
|
AllowedKeyIds = result.AllowedKeyIds.ToList(),
|
||||||
RevokedKeyIds = result.RevokedKeyIds.ToList(),
|
RevokedKeyIds = result.RevokedKeyIds.ToList(),
|
||||||
@@ -217,9 +218,10 @@ public static class KeyRotationEndpoints
|
|||||||
[FromRoute] string keyId,
|
[FromRoute] string keyId,
|
||||||
[FromQuery] DateTimeOffset? signedAt,
|
[FromQuery] DateTimeOffset? signedAt,
|
||||||
IKeyRotationService rotationService,
|
IKeyRotationService rotationService,
|
||||||
|
TimeProvider timeProvider,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var checkTime = signedAt ?? DateTimeOffset.UtcNow;
|
var checkTime = signedAt ?? timeProvider.GetUtcNow();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,8 +17,14 @@ builder.Services.AddAuthentication(StubBearerAuthenticationDefaults.Authenticati
|
|||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
builder.Services.AddSignerPipeline();
|
builder.Services.AddSignerPipeline();
|
||||||
|
|
||||||
|
// Configure TimeProvider for deterministic testing support
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
builder.Services.Configure<SignerEntitlementOptions>(options =>
|
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(
|
options.Tokens["valid-poe"] = new SignerEntitlementDefinition(
|
||||||
LicenseId: "LIC-TEST",
|
LicenseId: "LIC-TEST",
|
||||||
CustomerId: "CUST-TEST",
|
CustomerId: "CUST-TEST",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Signer.KeyManagement.Entities;
|
using StellaOps.Signer.KeyManagement.Entities;
|
||||||
|
|
||||||
namespace StellaOps.Signer.KeyManagement;
|
namespace StellaOps.Signer.KeyManagement;
|
||||||
@@ -22,17 +23,20 @@ public sealed class KeyRotationService : IKeyRotationService
|
|||||||
private readonly ILogger<KeyRotationService> _logger;
|
private readonly ILogger<KeyRotationService> _logger;
|
||||||
private readonly KeyRotationOptions _options;
|
private readonly KeyRotationOptions _options;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public KeyRotationService(
|
public KeyRotationService(
|
||||||
KeyManagementDbContext dbContext,
|
KeyManagementDbContext dbContext,
|
||||||
ILogger<KeyRotationService> logger,
|
ILogger<KeyRotationService> logger,
|
||||||
IOptions<KeyRotationOptions> options,
|
IOptions<KeyRotationOptions> options,
|
||||||
TimeProvider? timeProvider = null)
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_options = options?.Value ?? new KeyRotationOptions();
|
_options = options?.Value ?? new KeyRotationOptions();
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -85,7 +89,7 @@ public sealed class KeyRotationService : IKeyRotationService
|
|||||||
// Create key history entry
|
// Create key history entry
|
||||||
var keyEntry = new KeyHistoryEntity
|
var keyEntry = new KeyHistoryEntity
|
||||||
{
|
{
|
||||||
HistoryId = Guid.NewGuid(),
|
HistoryId = _guidProvider.NewGuid(),
|
||||||
AnchorId = anchorId,
|
AnchorId = anchorId,
|
||||||
KeyId = request.KeyId,
|
KeyId = request.KeyId,
|
||||||
PublicKey = request.PublicKey,
|
PublicKey = request.PublicKey,
|
||||||
@@ -106,7 +110,7 @@ public sealed class KeyRotationService : IKeyRotationService
|
|||||||
// Create audit log entry
|
// Create audit log entry
|
||||||
var auditEntry = new KeyAuditLogEntity
|
var auditEntry = new KeyAuditLogEntity
|
||||||
{
|
{
|
||||||
LogId = Guid.NewGuid(),
|
LogId = _guidProvider.NewGuid(),
|
||||||
AnchorId = anchorId,
|
AnchorId = anchorId,
|
||||||
KeyId = request.KeyId,
|
KeyId = request.KeyId,
|
||||||
Operation = KeyOperation.Add,
|
Operation = KeyOperation.Add,
|
||||||
@@ -209,7 +213,7 @@ public sealed class KeyRotationService : IKeyRotationService
|
|||||||
// Create audit log entry
|
// Create audit log entry
|
||||||
var auditEntry = new KeyAuditLogEntity
|
var auditEntry = new KeyAuditLogEntity
|
||||||
{
|
{
|
||||||
LogId = Guid.NewGuid(),
|
LogId = _guidProvider.NewGuid(),
|
||||||
AnchorId = anchorId,
|
AnchorId = anchorId,
|
||||||
KeyId = keyId,
|
KeyId = keyId,
|
||||||
Operation = KeyOperation.Revoke,
|
Operation = KeyOperation.Revoke,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="Migrations\*.sql">
|
<None Include="Migrations\*.sql">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Signer.KeyManagement.Entities;
|
using StellaOps.Signer.KeyManagement.Entities;
|
||||||
|
|
||||||
namespace StellaOps.Signer.KeyManagement;
|
namespace StellaOps.Signer.KeyManagement;
|
||||||
@@ -22,17 +23,20 @@ public sealed class TrustAnchorManager : ITrustAnchorManager
|
|||||||
private readonly IKeyRotationService _keyRotationService;
|
private readonly IKeyRotationService _keyRotationService;
|
||||||
private readonly ILogger<TrustAnchorManager> _logger;
|
private readonly ILogger<TrustAnchorManager> _logger;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public TrustAnchorManager(
|
public TrustAnchorManager(
|
||||||
KeyManagementDbContext dbContext,
|
KeyManagementDbContext dbContext,
|
||||||
IKeyRotationService keyRotationService,
|
IKeyRotationService keyRotationService,
|
||||||
ILogger<TrustAnchorManager> logger,
|
ILogger<TrustAnchorManager> logger,
|
||||||
TimeProvider? timeProvider = null)
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||||
_keyRotationService = keyRotationService ?? throw new ArgumentNullException(nameof(keyRotationService));
|
_keyRotationService = keyRotationService ?? throw new ArgumentNullException(nameof(keyRotationService));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -115,7 +119,7 @@ public sealed class TrustAnchorManager : ITrustAnchorManager
|
|||||||
|
|
||||||
var entity = new TrustAnchorEntity
|
var entity = new TrustAnchorEntity
|
||||||
{
|
{
|
||||||
AnchorId = Guid.NewGuid(),
|
AnchorId = _guidProvider.NewGuid(),
|
||||||
PurlPattern = request.PurlPattern,
|
PurlPattern = request.PurlPattern,
|
||||||
AllowedKeyIds = request.AllowedKeyIds?.ToList() ?? [],
|
AllowedKeyIds = request.AllowedKeyIds?.ToList() ?? [],
|
||||||
AllowedPredicateTypes = request.AllowedPredicateTypes?.ToList(),
|
AllowedPredicateTypes = request.AllowedPredicateTypes?.ToList(),
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
|||||||
private readonly JwtSecurityTokenHandler _tokenHandler;
|
private readonly JwtSecurityTokenHandler _tokenHandler;
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
private readonly FileSystemWatcher? _watcher;
|
private readonly FileSystemWatcher? _watcher;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private OidcTokenResult? _cachedToken;
|
private OidcTokenResult? _cachedToken;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public AmbientOidcTokenProvider(
|
public AmbientOidcTokenProvider(
|
||||||
OidcAmbientConfig config,
|
OidcAmbientConfig config,
|
||||||
ILogger<AmbientOidcTokenProvider> logger)
|
ILogger<AmbientOidcTokenProvider> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(config);
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
ArgumentNullException.ThrowIfNull(logger);
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
@@ -35,6 +37,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
|||||||
_config = config;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_tokenHandler = new JwtSecurityTokenHandler();
|
_tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
|
||||||
if (_config.WatchForChanges && File.Exists(_config.TokenPath))
|
if (_config.WatchForChanges && File.Exists(_config.TokenPath))
|
||||||
{
|
{
|
||||||
@@ -65,7 +68,8 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Check cache first
|
// 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;
|
return _cachedToken;
|
||||||
}
|
}
|
||||||
@@ -111,7 +115,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
|||||||
public OidcTokenResult? GetCachedToken()
|
public OidcTokenResult? GetCachedToken()
|
||||||
{
|
{
|
||||||
var cached = _cachedToken;
|
var cached = _cachedToken;
|
||||||
if (cached is null || cached.IsExpired)
|
if (cached is null || cached.IsExpiredAt(_timeProvider.GetUtcNow()))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -132,7 +136,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
|||||||
|
|
||||||
var expiresAt = jwt.ValidTo != DateTime.MinValue
|
var expiresAt = jwt.ValidTo != DateTime.MinValue
|
||||||
? new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)
|
? 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 subject = jwt.Subject;
|
||||||
var email = jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
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="publicKey">The public key bytes.</param>
|
||||||
/// <param name="privateKey">The private key bytes (will be copied).</param>
|
/// <param name="privateKey">The private key bytes (will be copied).</param>
|
||||||
/// <param name="algorithm">The algorithm identifier.</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(publicKey);
|
||||||
ArgumentNullException.ThrowIfNull(privateKey);
|
ArgumentNullException.ThrowIfNull(privateKey);
|
||||||
@@ -56,7 +57,7 @@ public sealed class EphemeralKeyPair : IDisposable
|
|||||||
_publicKey = (byte[])publicKey.Clone();
|
_publicKey = (byte[])publicKey.Clone();
|
||||||
_privateKey = (byte[])privateKey.Clone();
|
_privateKey = (byte[])privateKey.Clone();
|
||||||
Algorithm = algorithm;
|
Algorithm = algorithm;
|
||||||
CreatedAt = DateTimeOffset.UtcNow;
|
CreatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ public sealed record FulcioCertificateResult(
|
|||||||
public TimeSpan Validity => NotAfter - NotBefore;
|
public TimeSpan Validity => NotAfter - NotBefore;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if the certificate is currently valid.
|
/// Checks if the certificate is valid at the specified time.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Gets the full certificate chain including the leaf certificate.
|
/// Gets the full certificate chain including the leaf certificate.
|
||||||
|
|||||||
@@ -62,15 +62,20 @@ public sealed record OidcTokenResult
|
|||||||
public string? Email { get; init; }
|
public string? Email { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the token is expired.
|
/// Checks whether the token is expired at the specified time.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Whether the token will expire within the specified buffer time.
|
/// Checks whether the token will expire within the specified buffer time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool WillExpireSoon(TimeSpan buffer) =>
|
/// <param name="now">The current time.</param>
|
||||||
DateTimeOffset.UtcNow.Add(buffer) >= ExpiresAt;
|
/// <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>
|
/// <summary>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Consensus;
|
using StellaOps.VexLens.Consensus;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
using StellaOps.VexLens.Storage;
|
using StellaOps.VexLens.Storage;
|
||||||
@@ -26,16 +27,19 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||||
private readonly ILogger<PostgresConsensusProjectionStore> _logger;
|
private readonly ILogger<PostgresConsensusProjectionStore> _logger;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public PostgresConsensusProjectionStore(
|
public PostgresConsensusProjectionStore(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ILogger<PostgresConsensusProjectionStore> logger,
|
ILogger<PostgresConsensusProjectionStore> logger,
|
||||||
TimeProvider? timeProvider = null,
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null,
|
||||||
IConsensusEventEmitter? eventEmitter = null)
|
IConsensusEventEmitter? eventEmitter = null)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||||
_eventEmitter = eventEmitter;
|
_eventEmitter = eventEmitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +56,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
||||||
activity?.SetTag("productKey", result.ProductKey);
|
activity?.SetTag("productKey", result.ProductKey);
|
||||||
|
|
||||||
var projectionId = Guid.NewGuid();
|
var projectionId = _guidProvider.NewGuid();
|
||||||
var now = _timeProvider.GetUtcNow();
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
// Check for previous projection to track history
|
// Check for previous projection to track history
|
||||||
@@ -527,7 +531,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
// Always emit computed event
|
// Always emit computed event
|
||||||
await _eventEmitter.EmitConsensusComputedAsync(
|
await _eventEmitter.EmitConsensusComputedAsync(
|
||||||
new ConsensusComputedEvent(
|
new ConsensusComputedEvent(
|
||||||
EventId: Guid.NewGuid().ToString(),
|
EventId: _guidProvider.NewGuid().ToString(),
|
||||||
ProjectionId: projection.ProjectionId,
|
ProjectionId: projection.ProjectionId,
|
||||||
VulnerabilityId: projection.VulnerabilityId,
|
VulnerabilityId: projection.VulnerabilityId,
|
||||||
ProductKey: projection.ProductKey,
|
ProductKey: projection.ProductKey,
|
||||||
@@ -546,7 +550,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
{
|
{
|
||||||
await _eventEmitter.EmitStatusChangedAsync(
|
await _eventEmitter.EmitStatusChangedAsync(
|
||||||
new ConsensusStatusChangedEvent(
|
new ConsensusStatusChangedEvent(
|
||||||
EventId: Guid.NewGuid().ToString(),
|
EventId: _guidProvider.NewGuid().ToString(),
|
||||||
ProjectionId: projection.ProjectionId,
|
ProjectionId: projection.ProjectionId,
|
||||||
VulnerabilityId: projection.VulnerabilityId,
|
VulnerabilityId: projection.VulnerabilityId,
|
||||||
ProductKey: projection.ProductKey,
|
ProductKey: projection.ProductKey,
|
||||||
@@ -564,7 +568,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
{
|
{
|
||||||
await _eventEmitter.EmitConflictDetectedAsync(
|
await _eventEmitter.EmitConflictDetectedAsync(
|
||||||
new ConsensusConflictDetectedEvent(
|
new ConsensusConflictDetectedEvent(
|
||||||
EventId: Guid.NewGuid().ToString(),
|
EventId: _guidProvider.NewGuid().ToString(),
|
||||||
ProjectionId: projection.ProjectionId,
|
ProjectionId: projection.ProjectionId,
|
||||||
VulnerabilityId: projection.VulnerabilityId,
|
VulnerabilityId: projection.VulnerabilityId,
|
||||||
ProductKey: projection.ProductKey,
|
ProductKey: projection.ProductKey,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
|
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
|
||||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||||
|
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ public static class VexLensEndpointExtensions
|
|||||||
[FromQuery] DateTimeOffset? fromDate,
|
[FromQuery] DateTimeOffset? fromDate,
|
||||||
[FromQuery] DateTimeOffset? toDate,
|
[FromQuery] DateTimeOffset? toDate,
|
||||||
[FromServices] IGatingStatisticsStore statsStore,
|
[FromServices] IGatingStatisticsStore statsStore,
|
||||||
|
[FromServices] TimeProvider timeProvider,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -340,7 +341,7 @@ public static class VexLensEndpointExtensions
|
|||||||
TotalSurfaced: stats.TotalSurfaced,
|
TotalSurfaced: stats.TotalSurfaced,
|
||||||
TotalDamped: stats.TotalDamped,
|
TotalDamped: stats.TotalDamped,
|
||||||
AverageDampingPercent: stats.AverageDampingPercent,
|
AverageDampingPercent: stats.AverageDampingPercent,
|
||||||
ComputedAt: DateTimeOffset.UtcNow));
|
ComputedAt: timeProvider.GetUtcNow()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GateSnapshotAsync(
|
private static async Task<IResult> GateSnapshotAsync(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Consensus;
|
using StellaOps.VexLens.Consensus;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
using StellaOps.VexLens.Storage;
|
using StellaOps.VexLens.Storage;
|
||||||
@@ -43,17 +44,20 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService
|
|||||||
private readonly IConsensusProjectionStore _projectionStore;
|
private readonly IConsensusProjectionStore _projectionStore;
|
||||||
private readonly IVexConsensusEngine _consensusEngine;
|
private readonly IVexConsensusEngine _consensusEngine;
|
||||||
private readonly ITrustWeightEngine _trustWeightEngine;
|
private readonly ITrustWeightEngine _trustWeightEngine;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
private const string AlgorithmVersion = "1.0.0";
|
private const string AlgorithmVersion = "1.0.0";
|
||||||
|
|
||||||
public ConsensusRationaleService(
|
public ConsensusRationaleService(
|
||||||
IConsensusProjectionStore projectionStore,
|
IConsensusProjectionStore projectionStore,
|
||||||
IVexConsensusEngine consensusEngine,
|
IVexConsensusEngine consensusEngine,
|
||||||
ITrustWeightEngine trustWeightEngine)
|
ITrustWeightEngine trustWeightEngine,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_projectionStore = projectionStore;
|
_projectionStore = projectionStore;
|
||||||
_consensusEngine = consensusEngine;
|
_consensusEngine = consensusEngine;
|
||||||
_trustWeightEngine = trustWeightEngine;
|
_trustWeightEngine = trustWeightEngine;
|
||||||
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<GenerateRationaleResponse> GenerateRationaleAsync(
|
public async Task<GenerateRationaleResponse> GenerateRationaleAsync(
|
||||||
@@ -177,7 +181,7 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService
|
|||||||
var outputHash = ComputeOutputHash(result, contributions, conflicts);
|
var outputHash = ComputeOutputHash(result, contributions, conflicts);
|
||||||
|
|
||||||
var rationale = new DetailedConsensusRationale(
|
var rationale = new DetailedConsensusRationale(
|
||||||
RationaleId: $"rat-{Guid.NewGuid():N}",
|
RationaleId: $"rat-{_guidProvider.NewGuid():N}",
|
||||||
VulnerabilityId: result.VulnerabilityId,
|
VulnerabilityId: result.VulnerabilityId,
|
||||||
ProductKey: result.ProductKey,
|
ProductKey: result.ProductKey,
|
||||||
ConsensusStatus: result.ConsensusStatus,
|
ConsensusStatus: result.ConsensusStatus,
|
||||||
|
|||||||
@@ -137,19 +137,22 @@ public sealed class VexLensApiService : IVexLensApiService
|
|||||||
private readonly IConsensusProjectionStore _projectionStore;
|
private readonly IConsensusProjectionStore _projectionStore;
|
||||||
private readonly IIssuerDirectory _issuerDirectory;
|
private readonly IIssuerDirectory _issuerDirectory;
|
||||||
private readonly IVexStatementProvider _statementProvider;
|
private readonly IVexStatementProvider _statementProvider;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public VexLensApiService(
|
public VexLensApiService(
|
||||||
IVexConsensusEngine consensusEngine,
|
IVexConsensusEngine consensusEngine,
|
||||||
ITrustWeightEngine trustWeightEngine,
|
ITrustWeightEngine trustWeightEngine,
|
||||||
IConsensusProjectionStore projectionStore,
|
IConsensusProjectionStore projectionStore,
|
||||||
IIssuerDirectory issuerDirectory,
|
IIssuerDirectory issuerDirectory,
|
||||||
IVexStatementProvider statementProvider)
|
IVexStatementProvider statementProvider,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_consensusEngine = consensusEngine;
|
_consensusEngine = consensusEngine;
|
||||||
_trustWeightEngine = trustWeightEngine;
|
_trustWeightEngine = trustWeightEngine;
|
||||||
_projectionStore = projectionStore;
|
_projectionStore = projectionStore;
|
||||||
_issuerDirectory = issuerDirectory;
|
_issuerDirectory = issuerDirectory;
|
||||||
_statementProvider = statementProvider;
|
_statementProvider = statementProvider;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ComputeConsensusResponse> ComputeConsensusAsync(
|
public async Task<ComputeConsensusResponse> ComputeConsensusAsync(
|
||||||
@@ -164,7 +167,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// Compute trust weights
|
// Compute trust weights
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
var weightedStatements = new List<WeightedStatement>();
|
var weightedStatements = new List<WeightedStatement>();
|
||||||
|
|
||||||
foreach (var stmt in statements)
|
foreach (var stmt in statements)
|
||||||
@@ -237,7 +240,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// Compute trust weights
|
// Compute trust weights
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
var weightedStatements = new List<WeightedStatement>();
|
var weightedStatements = new List<WeightedStatement>();
|
||||||
|
|
||||||
foreach (var stmt in statements)
|
foreach (var stmt in statements)
|
||||||
@@ -293,7 +296,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
|||||||
var resolutionResult = await _consensusEngine.ComputeConsensusWithProofAsync(
|
var resolutionResult = await _consensusEngine.ComputeConsensusWithProofAsync(
|
||||||
consensusRequest,
|
consensusRequest,
|
||||||
proofContext,
|
proofContext,
|
||||||
TimeProvider.System,
|
_timeProvider,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// Store result if requested
|
// Store result if requested
|
||||||
@@ -348,7 +351,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
|||||||
TotalCount: request.Targets.Count,
|
TotalCount: request.Targets.Count,
|
||||||
SuccessCount: results.Count,
|
SuccessCount: results.Count,
|
||||||
FailureCount: failures,
|
FailureCount: failures,
|
||||||
CompletedAt: DateTimeOffset.UtcNow);
|
CompletedAt: _timeProvider.GetUtcNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ProjectionDetailResponse?> GetProjectionAsync(
|
public async Task<ProjectionDetailResponse?> GetProjectionAsync(
|
||||||
@@ -452,7 +455,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
|||||||
|
|
||||||
var withConflicts = projections.Count(p => p.ConflictCount > 0);
|
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);
|
var changesLast24h = projections.Count(p => p.StatusChanged && p.ComputedAt >= last24h);
|
||||||
|
|
||||||
return new ConsensusStatisticsResponse(
|
return new ConsensusStatisticsResponse(
|
||||||
@@ -462,7 +465,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
|||||||
AverageConfidence: avgConfidence,
|
AverageConfidence: avgConfidence,
|
||||||
ProjectionsWithConflicts: withConflicts,
|
ProjectionsWithConflicts: withConflicts,
|
||||||
StatusChangesLast24h: changesLast24h,
|
StatusChangesLast24h: changesLast24h,
|
||||||
ComputedAt: DateTimeOffset.UtcNow);
|
ComputedAt: _timeProvider.GetUtcNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IssuerListResponse> ListIssuersAsync(
|
public async Task<IssuerListResponse> ListIssuersAsync(
|
||||||
|
|||||||
@@ -472,15 +472,18 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService
|
|||||||
private readonly ISourceTrustScoreCalculator _scoreCalculator;
|
private readonly ISourceTrustScoreCalculator _scoreCalculator;
|
||||||
private readonly IConflictAuditStore? _auditStore;
|
private readonly IConflictAuditStore? _auditStore;
|
||||||
private readonly ITrustScoreHistoryStore? _historyStore;
|
private readonly ITrustScoreHistoryStore? _historyStore;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public TrustScorecardApiService(
|
public TrustScorecardApiService(
|
||||||
ISourceTrustScoreCalculator scoreCalculator,
|
ISourceTrustScoreCalculator scoreCalculator,
|
||||||
IConflictAuditStore? auditStore = null,
|
IConflictAuditStore? auditStore = null,
|
||||||
ITrustScoreHistoryStore? historyStore = null)
|
ITrustScoreHistoryStore? historyStore = null,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_scoreCalculator = scoreCalculator;
|
_scoreCalculator = scoreCalculator;
|
||||||
_auditStore = auditStore;
|
_auditStore = auditStore;
|
||||||
_historyStore = historyStore;
|
_historyStore = historyStore;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TrustScorecardResponse> GetScorecardAsync(
|
public async Task<TrustScorecardResponse> GetScorecardAsync(
|
||||||
@@ -544,7 +547,7 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService
|
|||||||
SignatureValidityRate = cachedScore.Breakdown.Verification.SignatureValidityRate,
|
SignatureValidityRate = cachedScore.Breakdown.Verification.SignatureValidityRate,
|
||||||
VerificationMethod = cachedScore.Breakdown.Verification.IssuerVerified ? "registry" : null
|
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(
|
var history = await _historyStore.GetHistoryAsync(
|
||||||
sourceId,
|
sourceId,
|
||||||
DateTimeOffset.UtcNow.AddDays(-days),
|
now.AddDays(-days),
|
||||||
DateTimeOffset.UtcNow,
|
now,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
if (history.Count == 0)
|
if (history.Count == 0)
|
||||||
@@ -622,7 +626,7 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService
|
|||||||
|
|
||||||
var current = history.LastOrDefault()?.CompositeScore ?? 0.0;
|
var current = history.LastOrDefault()?.CompositeScore ?? 0.0;
|
||||||
var thirtyDaysAgo = history
|
var thirtyDaysAgo = history
|
||||||
.Where(h => h.Timestamp >= DateTimeOffset.UtcNow.AddDays(-30))
|
.Where(h => h.Timestamp >= now.AddDays(-30))
|
||||||
.FirstOrDefault()?.CompositeScore ?? current;
|
.FirstOrDefault()?.CompositeScore ?? current;
|
||||||
var ninetyDaysAgo = history.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 Dictionary<string, CacheEntry> _cache = new();
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
private readonly int _maxEntries;
|
private readonly int _maxEntries;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private long _hitCount;
|
private long _hitCount;
|
||||||
private long _missCount;
|
private long _missCount;
|
||||||
private DateTimeOffset? _lastCleared;
|
private DateTimeOffset? _lastCleared;
|
||||||
|
|
||||||
public InMemoryConsensusRationaleCache(int maxEntries = 10000)
|
public InMemoryConsensusRationaleCache(int maxEntries = 10000, TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_maxEntries = maxEntries;
|
_maxEntries = maxEntries;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<DetailedConsensusRationale?> GetAsync(
|
public Task<DetailedConsensusRationale?> GetAsync(
|
||||||
@@ -163,7 +165,7 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
|||||||
return Task.FromResult<DetailedConsensusRationale?>(null);
|
return Task.FromResult<DetailedConsensusRationale?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.LastAccessed = DateTimeOffset.UtcNow;
|
entry.LastAccessed = _timeProvider.GetUtcNow();
|
||||||
Interlocked.Increment(ref _hitCount);
|
Interlocked.Increment(ref _hitCount);
|
||||||
return Task.FromResult<DetailedConsensusRationale?>(entry.Rationale);
|
return Task.FromResult<DetailedConsensusRationale?>(entry.Rationale);
|
||||||
}
|
}
|
||||||
@@ -187,12 +189,13 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
|||||||
EvictOldestEntry();
|
EvictOldestEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var now = _timeProvider.GetUtcNow();
|
||||||
_cache[cacheKey] = new CacheEntry
|
_cache[cacheKey] = new CacheEntry
|
||||||
{
|
{
|
||||||
Rationale = rationale,
|
Rationale = rationale,
|
||||||
Options = options ?? new CacheOptions(),
|
Options = options ?? new CacheOptions(),
|
||||||
Created = DateTimeOffset.UtcNow,
|
Created = now,
|
||||||
LastAccessed = DateTimeOffset.UtcNow
|
LastAccessed = now
|
||||||
};
|
};
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -254,7 +257,7 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
|||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_cache.Clear();
|
_cache.Clear();
|
||||||
_lastCleared = DateTimeOffset.UtcNow;
|
_lastCleared = _timeProvider.GetUtcNow();
|
||||||
return Task.CompletedTask;
|
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 &&
|
if (entry.Options.AbsoluteExpiration.HasValue &&
|
||||||
now >= entry.Options.AbsoluteExpiration.Value)
|
now >= entry.Options.AbsoluteExpiration.Value)
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ namespace StellaOps.VexLens.Consensus;
|
|||||||
public sealed class VexConsensusEngine : IVexConsensusEngine
|
public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||||
{
|
{
|
||||||
private ConsensusConfiguration _configuration;
|
private ConsensusConfiguration _configuration;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public VexConsensusEngine(ConsensusConfiguration? configuration = null)
|
public VexConsensusEngine(
|
||||||
|
ConsensusConfiguration? configuration = null,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_configuration = configuration ?? CreateDefaultConfiguration();
|
_configuration = configuration ?? CreateDefaultConfiguration();
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<VexConsensusResult> ComputeConsensusAsync(
|
public Task<VexConsensusResult> ComputeConsensusAsync(
|
||||||
@@ -559,7 +563,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
|||||||
stmt.Statement.Status,
|
stmt.Statement.Status,
|
||||||
stmt.Statement.Justification,
|
stmt.Statement.Justification,
|
||||||
weight,
|
weight,
|
||||||
GetStatementTimestamp(stmt.Statement),
|
GetStatementTimestamp(stmt.Statement, _timeProvider),
|
||||||
HasSignature(stmt.Weight));
|
HasSignature(stmt.Weight));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +578,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
|||||||
stmt.Statement.Status,
|
stmt.Statement.Status,
|
||||||
stmt.Statement.Justification,
|
stmt.Statement.Justification,
|
||||||
weight,
|
weight,
|
||||||
GetStatementTimestamp(stmt.Statement),
|
GetStatementTimestamp(stmt.Statement, _timeProvider),
|
||||||
HasSignature(stmt.Weight),
|
HasSignature(stmt.Weight),
|
||||||
reason);
|
reason);
|
||||||
}
|
}
|
||||||
@@ -704,7 +708,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
|||||||
stmt.Statement.Status,
|
stmt.Statement.Status,
|
||||||
stmt.Statement.Justification,
|
stmt.Statement.Justification,
|
||||||
weight,
|
weight,
|
||||||
GetStatementTimestamp(stmt.Statement),
|
GetStatementTimestamp(stmt.Statement, _timeProvider),
|
||||||
HasSignature(stmt.Weight));
|
HasSignature(stmt.Weight));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +723,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
|||||||
stmt.Statement.Status,
|
stmt.Statement.Status,
|
||||||
stmt.Statement.Justification,
|
stmt.Statement.Justification,
|
||||||
weight,
|
weight,
|
||||||
GetStatementTimestamp(stmt.Statement),
|
GetStatementTimestamp(stmt.Statement, _timeProvider),
|
||||||
HasSignature(stmt.Weight),
|
HasSignature(stmt.Weight),
|
||||||
reason);
|
reason);
|
||||||
}
|
}
|
||||||
@@ -1278,10 +1282,10 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
|||||||
(decimal)breakdown.StatusSpecificityWeight));
|
(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
|
// 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)
|
private static bool HasSignature(Trust.TrustWeightResult weight)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Consensus;
|
using StellaOps.VexLens.Consensus;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
using StellaOps.VexLens.Storage;
|
using StellaOps.VexLens.Storage;
|
||||||
@@ -273,12 +274,19 @@ public enum ExportFormat
|
|||||||
public sealed class ConsensusExportService : IConsensusExportService
|
public sealed class ConsensusExportService : IConsensusExportService
|
||||||
{
|
{
|
||||||
private readonly IConsensusProjectionStore _projectionStore;
|
private readonly IConsensusProjectionStore _projectionStore;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
private const string SnapshotVersion = "1.0.0";
|
private const string SnapshotVersion = "1.0.0";
|
||||||
|
|
||||||
public ConsensusExportService(IConsensusProjectionStore projectionStore)
|
public ConsensusExportService(
|
||||||
|
IConsensusProjectionStore projectionStore,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_projectionStore = projectionStore;
|
_projectionStore = projectionStore;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ConsensusSnapshot> CreateSnapshotAsync(
|
public async Task<ConsensusSnapshot> CreateSnapshotAsync(
|
||||||
@@ -338,12 +346,12 @@ public sealed class ConsensusExportService : IConsensusExportService
|
|||||||
.GroupBy(p => p.Status)
|
.GroupBy(p => p.Status)
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
var snapshotId = $"snap-{Guid.NewGuid():N}";
|
var snapshotId = $"snap-{_guidProvider.NewGuid():N}";
|
||||||
var contentHash = ComputeContentHash(projections);
|
var contentHash = ComputeContentHash(projections);
|
||||||
|
|
||||||
return new ConsensusSnapshot(
|
return new ConsensusSnapshot(
|
||||||
SnapshotId: snapshotId,
|
SnapshotId: snapshotId,
|
||||||
CreatedAt: DateTimeOffset.UtcNow,
|
CreatedAt: _timeProvider.GetUtcNow(),
|
||||||
Version: SnapshotVersion,
|
Version: SnapshotVersion,
|
||||||
TenantId: request.TenantId,
|
TenantId: request.TenantId,
|
||||||
Projections: projections,
|
Projections: projections,
|
||||||
@@ -400,13 +408,13 @@ public sealed class ConsensusExportService : IConsensusExportService
|
|||||||
|
|
||||||
// For a true incremental, we'd compare with the previous snapshot
|
// For a true incremental, we'd compare with the previous snapshot
|
||||||
// Here we just return new/updated since the timestamp
|
// 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);
|
var contentHash = ComputeContentHash(current.Projections);
|
||||||
|
|
||||||
return new IncrementalSnapshot(
|
return new IncrementalSnapshot(
|
||||||
SnapshotId: snapshotId,
|
SnapshotId: snapshotId,
|
||||||
PreviousSnapshotId: lastSnapshotId,
|
PreviousSnapshotId: lastSnapshotId,
|
||||||
CreatedAt: DateTimeOffset.UtcNow,
|
CreatedAt: _timeProvider.GetUtcNow(),
|
||||||
Version: SnapshotVersion,
|
Version: SnapshotVersion,
|
||||||
Added: current.Projections,
|
Added: current.Projections,
|
||||||
Removed: [], // Would need previous snapshot to determine removed
|
Removed: [], // Would need previous snapshot to determine removed
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
|
|
||||||
namespace StellaOps.VexLens.Normalization;
|
namespace StellaOps.VexLens.Normalization;
|
||||||
@@ -12,6 +13,13 @@ namespace StellaOps.VexLens.Normalization;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CsafVexNormalizer : IVexNormalizer
|
public sealed class CsafVexNormalizer : IVexNormalizer
|
||||||
{
|
{
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
|
public CsafVexNormalizer(IGuidProvider? guidProvider = null)
|
||||||
|
{
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
public VexSourceFormat SourceFormat => VexSourceFormat.CsafVex;
|
public VexSourceFormat SourceFormat => VexSourceFormat.CsafVex;
|
||||||
|
|
||||||
public bool CanNormalize(string content)
|
public bool CanNormalize(string content)
|
||||||
@@ -77,7 +85,7 @@ public sealed class CsafVexNormalizer : IVexNormalizer
|
|||||||
var documentId = ExtractDocumentId(documentElement);
|
var documentId = ExtractDocumentId(documentElement);
|
||||||
if (string.IsNullOrWhiteSpace(documentId))
|
if (string.IsNullOrWhiteSpace(documentId))
|
||||||
{
|
{
|
||||||
documentId = $"csaf:{Guid.NewGuid():N}";
|
documentId = $"csaf:{_guidProvider.NewGuid():N}";
|
||||||
warnings.Add(new NormalizationWarning(
|
warnings.Add(new NormalizationWarning(
|
||||||
"WARN_CSAF_001",
|
"WARN_CSAF_001",
|
||||||
"Document tracking ID not found; generated a random ID",
|
"Document tracking ID not found; generated a random ID",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
|
|
||||||
namespace StellaOps.VexLens.Normalization;
|
namespace StellaOps.VexLens.Normalization;
|
||||||
@@ -12,6 +13,13 @@ namespace StellaOps.VexLens.Normalization;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||||
{
|
{
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
|
public CycloneDxVexNormalizer(IGuidProvider? guidProvider = null)
|
||||||
|
{
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
public VexSourceFormat SourceFormat => VexSourceFormat.CycloneDxVex;
|
public VexSourceFormat SourceFormat => VexSourceFormat.CycloneDxVex;
|
||||||
|
|
||||||
public bool CanNormalize(string content)
|
public bool CanNormalize(string content)
|
||||||
@@ -65,7 +73,7 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
|||||||
var documentId = ExtractDocumentId(root);
|
var documentId = ExtractDocumentId(root);
|
||||||
if (string.IsNullOrWhiteSpace(documentId))
|
if (string.IsNullOrWhiteSpace(documentId))
|
||||||
{
|
{
|
||||||
documentId = $"cyclonedx:{Guid.NewGuid():N}";
|
documentId = $"cyclonedx:{_guidProvider.NewGuid():N}";
|
||||||
warnings.Add(new NormalizationWarning(
|
warnings.Add(new NormalizationWarning(
|
||||||
"WARN_CDX_001",
|
"WARN_CDX_001",
|
||||||
"Serial number not found; generated a random ID",
|
"Serial number not found; generated a random ID",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
|
|
||||||
namespace StellaOps.VexLens.Normalization;
|
namespace StellaOps.VexLens.Normalization;
|
||||||
@@ -11,6 +12,13 @@ namespace StellaOps.VexLens.Normalization;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OpenVexNormalizer : IVexNormalizer
|
public sealed class OpenVexNormalizer : IVexNormalizer
|
||||||
{
|
{
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
|
public OpenVexNormalizer(IGuidProvider? guidProvider = null)
|
||||||
|
{
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
public VexSourceFormat SourceFormat => VexSourceFormat.OpenVex;
|
public VexSourceFormat SourceFormat => VexSourceFormat.OpenVex;
|
||||||
|
|
||||||
public bool CanNormalize(string content)
|
public bool CanNormalize(string content)
|
||||||
@@ -58,7 +66,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
|||||||
var documentId = ExtractDocumentId(root);
|
var documentId = ExtractDocumentId(root);
|
||||||
if (string.IsNullOrWhiteSpace(documentId))
|
if (string.IsNullOrWhiteSpace(documentId))
|
||||||
{
|
{
|
||||||
documentId = $"openvex:{Guid.NewGuid():N}";
|
documentId = $"openvex:{_guidProvider.NewGuid():N}";
|
||||||
warnings.Add(new NormalizationWarning(
|
warnings.Add(new NormalizationWarning(
|
||||||
"WARN_OPENVEX_001",
|
"WARN_OPENVEX_001",
|
||||||
"Document ID not found; generated a random ID",
|
"Document ID not found; generated a random ID",
|
||||||
@@ -207,7 +215,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
private IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||||
JsonElement root,
|
JsonElement root,
|
||||||
List<NormalizationWarning> warnings,
|
List<NormalizationWarning> warnings,
|
||||||
ref int skipped)
|
ref int skipped)
|
||||||
@@ -227,7 +235,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
|||||||
|
|
||||||
foreach (var stmt in statementsArray.EnumerateArray())
|
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)
|
if (statement != null)
|
||||||
{
|
{
|
||||||
statements.Add(statement);
|
statements.Add(statement);
|
||||||
@@ -243,7 +251,8 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
|||||||
JsonElement stmt,
|
JsonElement stmt,
|
||||||
int index,
|
int index,
|
||||||
List<NormalizationWarning> warnings,
|
List<NormalizationWarning> warnings,
|
||||||
ref int skipped)
|
ref int skipped,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
// Extract vulnerability
|
// Extract vulnerability
|
||||||
string? vulnerabilityId = null;
|
string? vulnerabilityId = null;
|
||||||
@@ -298,7 +307,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
|||||||
{
|
{
|
||||||
foreach (var prod in productsArray.EnumerateArray())
|
foreach (var prod in productsArray.EnumerateArray())
|
||||||
{
|
{
|
||||||
var product = ExtractProduct(prod);
|
var product = ExtractProduct(prod, guidProvider);
|
||||||
if (product != null)
|
if (product != null)
|
||||||
{
|
{
|
||||||
products.Add(product);
|
products.Add(product);
|
||||||
@@ -378,7 +387,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
|||||||
LastSeen: timestamp);
|
LastSeen: timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NormalizedProduct? ExtractProduct(JsonElement prod)
|
private static NormalizedProduct? ExtractProduct(JsonElement prod, IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
string? key = null;
|
string? key = null;
|
||||||
string? name = null;
|
string? name = null;
|
||||||
@@ -423,8 +432,9 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fallbackGuid = guidProvider?.NewGuid() ?? Guid.NewGuid();
|
||||||
return new NormalizedProduct(
|
return new NormalizedProduct(
|
||||||
Key: key ?? purl ?? cpe ?? $"unknown-{Guid.NewGuid():N}",
|
Key: key ?? purl ?? cpe ?? $"unknown-{fallbackGuid:N}",
|
||||||
Name: name,
|
Name: name,
|
||||||
Version: version,
|
Version: version,
|
||||||
Purl: purl,
|
Purl: purl,
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
private readonly IVexConsensusEngine _consensusEngine;
|
private readonly IVexConsensusEngine _consensusEngine;
|
||||||
private readonly IConsensusProjectionStore _projectionStore;
|
private readonly IConsensusProjectionStore _projectionStore;
|
||||||
private readonly IConsensusExportService _exportService;
|
private readonly IConsensusExportService _exportService;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
private const string SchemaVersion = "1.0.0";
|
private const string SchemaVersion = "1.0.0";
|
||||||
|
|
||||||
@@ -172,11 +173,13 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
public ConsensusJobService(
|
public ConsensusJobService(
|
||||||
IVexConsensusEngine consensusEngine,
|
IVexConsensusEngine consensusEngine,
|
||||||
IConsensusProjectionStore projectionStore,
|
IConsensusProjectionStore projectionStore,
|
||||||
IConsensusExportService exportService)
|
IConsensusExportService exportService,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_consensusEngine = consensusEngine;
|
_consensusEngine = consensusEngine;
|
||||||
_projectionStore = projectionStore;
|
_projectionStore = projectionStore;
|
||||||
_exportService = exportService;
|
_exportService = exportService;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ConsensusJobRequest CreateComputeJob(
|
public ConsensusJobRequest CreateComputeJob(
|
||||||
@@ -299,7 +302,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
JobType: ConsensusJobTypes.SnapshotCreate,
|
JobType: ConsensusJobTypes.SnapshotCreate,
|
||||||
TenantId: request.TenantId,
|
TenantId: request.TenantId,
|
||||||
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.SnapshotCreate),
|
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.SnapshotCreate),
|
||||||
IdempotencyKey: $"snapshot:{requestHash}:{DateTimeOffset.UtcNow:yyyyMMddHHmm}",
|
IdempotencyKey: $"snapshot:{requestHash}:{_timeProvider.GetUtcNow():yyyyMMddHHmm}",
|
||||||
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +310,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
ConsensusJobRequest request,
|
ConsensusJobRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var startTime = DateTimeOffset.UtcNow;
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -350,7 +353,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
ConsensusJobRequest request,
|
ConsensusJobRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var startTime = DateTimeOffset.UtcNow;
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
var payload = JsonSerializer.Deserialize<ComputePayload>(request.Payload, JsonOptions)
|
var payload = JsonSerializer.Deserialize<ComputePayload>(request.Payload, JsonOptions)
|
||||||
?? throw new InvalidOperationException("Invalid compute payload");
|
?? throw new InvalidOperationException("Invalid compute payload");
|
||||||
|
|
||||||
@@ -363,7 +366,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
JobType: request.JobType,
|
JobType: request.JobType,
|
||||||
ItemsProcessed: 1,
|
ItemsProcessed: 1,
|
||||||
ItemsFailed: 0,
|
ItemsFailed: 0,
|
||||||
Duration: DateTimeOffset.UtcNow - startTime,
|
Duration: _timeProvider.GetUtcNow() - startTime,
|
||||||
ResultPayload: JsonSerializer.Serialize(new
|
ResultPayload: JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
vulnerabilityId = payload.VulnerabilityId,
|
vulnerabilityId = payload.VulnerabilityId,
|
||||||
@@ -377,7 +380,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
ConsensusJobRequest request,
|
ConsensusJobRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var startTime = DateTimeOffset.UtcNow;
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
var payload = JsonSerializer.Deserialize<BatchComputePayload>(request.Payload, JsonOptions)
|
var payload = JsonSerializer.Deserialize<BatchComputePayload>(request.Payload, JsonOptions)
|
||||||
?? throw new InvalidOperationException("Invalid batch compute payload");
|
?? throw new InvalidOperationException("Invalid batch compute payload");
|
||||||
|
|
||||||
@@ -389,7 +392,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
JobType: request.JobType,
|
JobType: request.JobType,
|
||||||
ItemsProcessed: itemCount,
|
ItemsProcessed: itemCount,
|
||||||
ItemsFailed: 0,
|
ItemsFailed: 0,
|
||||||
Duration: DateTimeOffset.UtcNow - startTime,
|
Duration: _timeProvider.GetUtcNow() - startTime,
|
||||||
ResultPayload: JsonSerializer.Serialize(new { processedCount = itemCount }, JsonOptions),
|
ResultPayload: JsonSerializer.Serialize(new { processedCount = itemCount }, JsonOptions),
|
||||||
ErrorMessage: null);
|
ErrorMessage: null);
|
||||||
}
|
}
|
||||||
@@ -398,7 +401,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
ConsensusJobRequest request,
|
ConsensusJobRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var startTime = DateTimeOffset.UtcNow;
|
var startTime = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
// Create snapshot using export service
|
// Create snapshot using export service
|
||||||
var snapshotRequest = ConsensusExportExtensions.FullExportRequest(request.TenantId);
|
var snapshotRequest = ConsensusExportExtensions.FullExportRequest(request.TenantId);
|
||||||
@@ -409,7 +412,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
JobType: request.JobType,
|
JobType: request.JobType,
|
||||||
ItemsProcessed: snapshot.Projections.Count,
|
ItemsProcessed: snapshot.Projections.Count,
|
||||||
ItemsFailed: 0,
|
ItemsFailed: 0,
|
||||||
Duration: DateTimeOffset.UtcNow - startTime,
|
Duration: _timeProvider.GetUtcNow() - startTime,
|
||||||
ResultPayload: JsonSerializer.Serialize(new
|
ResultPayload: JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
snapshotId = snapshot.SnapshotId,
|
snapshotId = snapshot.SnapshotId,
|
||||||
@@ -419,14 +422,14 @@ public sealed class ConsensusJobService : IConsensusJobService
|
|||||||
ErrorMessage: null);
|
ErrorMessage: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ConsensusJobResult CreateFailedResult(string jobType, DateTimeOffset startTime, string error)
|
private ConsensusJobResult CreateFailedResult(string jobType, DateTimeOffset startTime, string error)
|
||||||
{
|
{
|
||||||
return new ConsensusJobResult(
|
return new ConsensusJobResult(
|
||||||
Success: false,
|
Success: false,
|
||||||
JobType: jobType,
|
JobType: jobType,
|
||||||
ItemsProcessed: 0,
|
ItemsProcessed: 0,
|
||||||
ItemsFailed: 1,
|
ItemsFailed: 1,
|
||||||
Duration: DateTimeOffset.UtcNow - startTime,
|
Duration: _timeProvider.GetUtcNow() - startTime,
|
||||||
ResultPayload: null,
|
ResultPayload: null,
|
||||||
ErrorMessage: error);
|
ErrorMessage: error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Consensus;
|
using StellaOps.VexLens.Consensus;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
using StellaOps.VexLens.Storage;
|
using StellaOps.VexLens.Storage;
|
||||||
@@ -14,6 +15,8 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
|||||||
{
|
{
|
||||||
private readonly IOrchestratorLedgerClient? _ledgerClient;
|
private readonly IOrchestratorLedgerClient? _ledgerClient;
|
||||||
private readonly OrchestratorEventOptions _options;
|
private readonly OrchestratorEventOptions _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -23,10 +26,14 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
|||||||
|
|
||||||
public OrchestratorLedgerEventEmitter(
|
public OrchestratorLedgerEventEmitter(
|
||||||
IOrchestratorLedgerClient? ledgerClient = null,
|
IOrchestratorLedgerClient? ledgerClient = null,
|
||||||
OrchestratorEventOptions? options = null)
|
OrchestratorEventOptions? options = null,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_ledgerClient = ledgerClient;
|
_ledgerClient = ledgerClient;
|
||||||
_options = options ?? OrchestratorEventOptions.Default;
|
_options = options ?? OrchestratorEventOptions.Default;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task EmitConsensusComputedAsync(
|
public async Task EmitConsensusComputedAsync(
|
||||||
@@ -144,11 +151,11 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
|||||||
if (_ledgerClient == null) return;
|
if (_ledgerClient == null) return;
|
||||||
|
|
||||||
var alertEvent = new LedgerEvent(
|
var alertEvent = new LedgerEvent(
|
||||||
EventId: $"alert-{Guid.NewGuid():N}",
|
EventId: $"alert-{_guidProvider.NewGuid():N}",
|
||||||
EventType: ConsensusEventTypes.Alert,
|
EventType: ConsensusEventTypes.Alert,
|
||||||
TenantId: @event.TenantId,
|
TenantId: @event.TenantId,
|
||||||
CorrelationId: @event.EventId,
|
CorrelationId: @event.EventId,
|
||||||
OccurredAt: DateTimeOffset.UtcNow,
|
OccurredAt: _timeProvider.GetUtcNow(),
|
||||||
IdempotencyKey: $"alert-status-{@event.ProjectionId}-{@event.NewStatus}",
|
IdempotencyKey: $"alert-status-{@event.ProjectionId}-{@event.NewStatus}",
|
||||||
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
|
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
|
||||||
Payload: JsonSerializer.Serialize(new
|
Payload: JsonSerializer.Serialize(new
|
||||||
@@ -174,11 +181,11 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
|||||||
if (_ledgerClient == null) return;
|
if (_ledgerClient == null) return;
|
||||||
|
|
||||||
var alertEvent = new LedgerEvent(
|
var alertEvent = new LedgerEvent(
|
||||||
EventId: $"alert-{Guid.NewGuid():N}",
|
EventId: $"alert-{_guidProvider.NewGuid():N}",
|
||||||
EventType: ConsensusEventTypes.Alert,
|
EventType: ConsensusEventTypes.Alert,
|
||||||
TenantId: @event.TenantId,
|
TenantId: @event.TenantId,
|
||||||
CorrelationId: @event.EventId,
|
CorrelationId: @event.EventId,
|
||||||
OccurredAt: DateTimeOffset.UtcNow,
|
OccurredAt: _timeProvider.GetUtcNow(),
|
||||||
IdempotencyKey: $"alert-conflict-{@event.ProjectionId}",
|
IdempotencyKey: $"alert-conflict-{@event.ProjectionId}",
|
||||||
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
|
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
|
||||||
Payload: JsonSerializer.Serialize(new
|
Payload: JsonSerializer.Serialize(new
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||||
|
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Consensus;
|
using StellaOps.VexLens.Consensus;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ namespace StellaOps.VexLens.Proof;
|
|||||||
public sealed class VexProofBuilder
|
public sealed class VexProofBuilder
|
||||||
{
|
{
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
private readonly List<VexProofStatement> _statements = [];
|
private readonly List<VexProofStatement> _statements = [];
|
||||||
private readonly List<VexProofMergeStep> _mergeSteps = [];
|
private readonly List<VexProofMergeStep> _mergeSteps = [];
|
||||||
private readonly List<VexProofConflict> _conflicts = [];
|
private readonly List<VexProofConflict> _conflicts = [];
|
||||||
@@ -48,11 +50,12 @@ public sealed class VexProofBuilder
|
|||||||
private decimal _conditionCoverage = 1.0m;
|
private decimal _conditionCoverage = 1.0m;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new VexProofBuilder with the specified time provider.
|
/// Creates a new VexProofBuilder with the specified time provider and GUID provider.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public VexProofBuilder(TimeProvider timeProvider)
|
public VexProofBuilder(TimeProvider timeProvider, IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -533,10 +536,10 @@ public sealed class VexProofBuilder
|
|||||||
_ => ConfidenceTier.VeryLow
|
_ => 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 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}";
|
return $"proof-{timePart}-{randomPart}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
<!-- NG-001: Noise-gating dependencies -->
|
<!-- NG-001: Noise-gating dependencies -->
|
||||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
||||||
<ProjectReference Include="..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.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>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Exclude legacy folders with external dependencies -->
|
<!-- Exclude legacy folders with external dependencies -->
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Consensus;
|
using StellaOps.VexLens.Consensus;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
using StellaOps.VexLens.Services;
|
using StellaOps.VexLens.Services;
|
||||||
@@ -16,13 +17,19 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||||
// LIN-BE-009: Delta service for computing VEX deltas on status change
|
// LIN-BE-009: Delta service for computing VEX deltas on status change
|
||||||
private readonly IVexDeltaComputeService? _deltaComputeService;
|
private readonly IVexDeltaComputeService? _deltaComputeService;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public InMemoryConsensusProjectionStore(
|
public InMemoryConsensusProjectionStore(
|
||||||
IConsensusEventEmitter? eventEmitter = null,
|
IConsensusEventEmitter? eventEmitter = null,
|
||||||
IVexDeltaComputeService? deltaComputeService = null)
|
IVexDeltaComputeService? deltaComputeService = null,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_eventEmitter = eventEmitter;
|
_eventEmitter = eventEmitter;
|
||||||
_deltaComputeService = deltaComputeService;
|
_deltaComputeService = deltaComputeService;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ConsensusProjection> StoreAsync(
|
public async Task<ConsensusProjection> StoreAsync(
|
||||||
@@ -31,7 +38,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var key = GetKey(result.VulnerabilityId, result.ProductKey, options.TenantId);
|
var key = GetKey(result.VulnerabilityId, result.ProductKey, options.TenantId);
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
// Get previous projection for history tracking
|
// Get previous projection for history tracking
|
||||||
ConsensusProjection? previous = null;
|
ConsensusProjection? previous = null;
|
||||||
@@ -52,7 +59,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
var projection = new ConsensusProjection(
|
var projection = new ConsensusProjection(
|
||||||
ProjectionId: $"proj-{Guid.NewGuid():N}",
|
ProjectionId: $"proj-{_guidProvider.NewGuid():N}",
|
||||||
VulnerabilityId: result.VulnerabilityId,
|
VulnerabilityId: result.VulnerabilityId,
|
||||||
ProductKey: result.ProductKey,
|
ProductKey: result.ProductKey,
|
||||||
TenantId: options.TenantId,
|
TenantId: options.TenantId,
|
||||||
@@ -283,12 +290,12 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
{
|
{
|
||||||
if (_eventEmitter == null) return;
|
if (_eventEmitter == null) return;
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
// Always emit computed event
|
// Always emit computed event
|
||||||
await _eventEmitter.EmitConsensusComputedAsync(
|
await _eventEmitter.EmitConsensusComputedAsync(
|
||||||
new ConsensusComputedEvent(
|
new ConsensusComputedEvent(
|
||||||
EventId: $"evt-{Guid.NewGuid():N}",
|
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||||
ProjectionId: projection.ProjectionId,
|
ProjectionId: projection.ProjectionId,
|
||||||
VulnerabilityId: projection.VulnerabilityId,
|
VulnerabilityId: projection.VulnerabilityId,
|
||||||
ProductKey: projection.ProductKey,
|
ProductKey: projection.ProductKey,
|
||||||
@@ -307,7 +314,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
{
|
{
|
||||||
await _eventEmitter.EmitStatusChangedAsync(
|
await _eventEmitter.EmitStatusChangedAsync(
|
||||||
new ConsensusStatusChangedEvent(
|
new ConsensusStatusChangedEvent(
|
||||||
EventId: $"evt-{Guid.NewGuid():N}",
|
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||||
ProjectionId: projection.ProjectionId,
|
ProjectionId: projection.ProjectionId,
|
||||||
VulnerabilityId: projection.VulnerabilityId,
|
VulnerabilityId: projection.VulnerabilityId,
|
||||||
ProductKey: projection.ProductKey,
|
ProductKey: projection.ProductKey,
|
||||||
@@ -325,7 +332,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
await _deltaComputeService.ComputeAndStoreAsync(
|
await _deltaComputeService.ComputeAndStoreAsync(
|
||||||
new VexStatusChangeContext
|
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,
|
VulnerabilityId = projection.VulnerabilityId,
|
||||||
ProductKey = projection.ProductKey,
|
ProductKey = projection.ProductKey,
|
||||||
ArtifactDigest = projection.ProductKey, // Use ProductKey as artifact identifier
|
ArtifactDigest = projection.ProductKey, // Use ProductKey as artifact identifier
|
||||||
@@ -355,7 +362,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
|||||||
|
|
||||||
await _eventEmitter.EmitConflictDetectedAsync(
|
await _eventEmitter.EmitConflictDetectedAsync(
|
||||||
new ConsensusConflictDetectedEvent(
|
new ConsensusConflictDetectedEvent(
|
||||||
EventId: $"evt-{Guid.NewGuid():N}",
|
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||||
ProjectionId: projection.ProjectionId,
|
ProjectionId: projection.ProjectionId,
|
||||||
VulnerabilityId: projection.VulnerabilityId,
|
VulnerabilityId: projection.VulnerabilityId,
|
||||||
ProductKey: projection.ProductKey,
|
ProductKey: projection.ProductKey,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.VexLens.Consensus;
|
using StellaOps.VexLens.Consensus;
|
||||||
using StellaOps.VexLens.Models;
|
using StellaOps.VexLens.Models;
|
||||||
using StellaOps.VexLens.Options;
|
using StellaOps.VexLens.Options;
|
||||||
@@ -28,19 +29,22 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
|||||||
private readonly ILogger<PostgresConsensusProjectionStoreProxy> _logger;
|
private readonly ILogger<PostgresConsensusProjectionStoreProxy> _logger;
|
||||||
private readonly VexLensStorageOptions _options;
|
private readonly VexLensStorageOptions _options;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public PostgresConsensusProjectionStoreProxy(
|
public PostgresConsensusProjectionStoreProxy(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ILogger<PostgresConsensusProjectionStoreProxy> logger,
|
ILogger<PostgresConsensusProjectionStoreProxy> logger,
|
||||||
IConsensusEventEmitter? eventEmitter = null,
|
IConsensusEventEmitter? eventEmitter = null,
|
||||||
VexLensStorageOptions? options = null,
|
VexLensStorageOptions? options = null,
|
||||||
TimeProvider? timeProvider = null)
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_eventEmitter = eventEmitter;
|
_eventEmitter = eventEmitter;
|
||||||
_options = options ?? new VexLensStorageOptions();
|
_options = options ?? new VexLensStorageOptions();
|
||||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string Schema = "vexlens";
|
private const string Schema = "vexlens";
|
||||||
@@ -108,7 +112,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
|||||||
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
||||||
activity?.SetTag("productKey", result.ProductKey);
|
activity?.SetTag("productKey", result.ProductKey);
|
||||||
|
|
||||||
var projectionId = Guid.NewGuid();
|
var projectionId = _guidProvider.NewGuid();
|
||||||
var now = _timeProvider.GetUtcNow();
|
var now = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
// Check for previous projection to track history
|
// Check for previous projection to track history
|
||||||
@@ -517,7 +521,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
|||||||
|
|
||||||
var now = _timeProvider.GetUtcNow();
|
var now = _timeProvider.GetUtcNow();
|
||||||
var computedEvent = new ConsensusComputedEvent(
|
var computedEvent = new ConsensusComputedEvent(
|
||||||
EventId: $"evt-{Guid.NewGuid():N}",
|
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||||
ProjectionId: projection.ProjectionId,
|
ProjectionId: projection.ProjectionId,
|
||||||
VulnerabilityId: projection.VulnerabilityId,
|
VulnerabilityId: projection.VulnerabilityId,
|
||||||
ProductKey: projection.ProductKey,
|
ProductKey: projection.ProductKey,
|
||||||
@@ -535,7 +539,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
|||||||
if (projection.StatusChanged && previous is not null)
|
if (projection.StatusChanged && previous is not null)
|
||||||
{
|
{
|
||||||
var changedEvent = new ConsensusStatusChangedEvent(
|
var changedEvent = new ConsensusStatusChangedEvent(
|
||||||
EventId: $"evt-{Guid.NewGuid():N}",
|
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||||
ProjectionId: projection.ProjectionId,
|
ProjectionId: projection.ProjectionId,
|
||||||
VulnerabilityId: projection.VulnerabilityId,
|
VulnerabilityId: projection.VulnerabilityId,
|
||||||
ProductKey: projection.ProductKey,
|
ProductKey: projection.ProductKey,
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ public sealed record SourceTrustScoreRequest
|
|||||||
public required SourceVerificationSummary VerificationSummary { get; init; }
|
public required SourceVerificationSummary VerificationSummary { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Time at which to evaluate the score.
|
/// Time at which to evaluate the score. Required for determinism.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTimeOffset EvaluationTime { get; init; } = DateTimeOffset.UtcNow;
|
public required DateTimeOffset EvaluationTime { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Previous score for trend calculation.
|
/// Previous score for trend calculation.
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache
|
|||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||||
private readonly Timer _cleanupTimer;
|
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
|
// Clean up expired entries every 5 minutes
|
||||||
_cleanupTimer = new Timer(CleanupExpiredEntries, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
|
_cleanupTimer = new Timer(CleanupExpiredEntries, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<VexSourceTrustScore?> GetAsync(string sourceId, CancellationToken cancellationToken = default)
|
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);
|
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)
|
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;
|
_cache[sourceId] = entry;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -41,7 +43,7 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache
|
|||||||
|
|
||||||
private void CleanupExpiredEntries(object? state)
|
private void CleanupExpiredEntries(object? state)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
var expiredKeys = _cache
|
var expiredKeys = _cache
|
||||||
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
||||||
.Select(kvp => kvp.Key)
|
.Select(kvp => kvp.Key)
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ public sealed class ProvenanceChainValidator : IProvenanceChainValidator
|
|||||||
{
|
{
|
||||||
private readonly ILogger<ProvenanceChainValidator> _logger;
|
private readonly ILogger<ProvenanceChainValidator> _logger;
|
||||||
private readonly IIssuerDirectory _issuerDirectory;
|
private readonly IIssuerDirectory _issuerDirectory;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
public ProvenanceChainValidator(
|
public ProvenanceChainValidator(
|
||||||
ILogger<ProvenanceChainValidator> logger,
|
ILogger<ProvenanceChainValidator> logger,
|
||||||
IIssuerDirectory issuerDirectory)
|
IIssuerDirectory issuerDirectory,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_issuerDirectory = issuerDirectory;
|
_issuerDirectory = issuerDirectory;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ProvenanceValidationResult> ValidateAsync(
|
public async Task<ProvenanceValidationResult> ValidateAsync(
|
||||||
@@ -44,7 +47,7 @@ public sealed class ProvenanceChainValidator : IProvenanceChainValidator
|
|||||||
// Validate chain age
|
// Validate chain age
|
||||||
if (options.MaxChainAge.HasValue)
|
if (options.MaxChainAge.HasValue)
|
||||||
{
|
{
|
||||||
var chainAge = DateTimeOffset.UtcNow - chain.Origin.Timestamp;
|
var chainAge = _timeProvider.GetUtcNow() - chain.Origin.Timestamp;
|
||||||
if (chainAge > options.MaxChainAge.Value)
|
if (chainAge > options.MaxChainAge.Value)
|
||||||
{
|
{
|
||||||
issues.Add(new ProvenanceIssue
|
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, IssuerRecord> _issuers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private readonly ConcurrentDictionary<string, string> _fingerprintToIssuer = 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(
|
public Task<IssuerRecord?> GetIssuerAsync(
|
||||||
string issuerId,
|
string issuerId,
|
||||||
@@ -86,7 +92,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
|||||||
IssuerRegistration registration,
|
IssuerRegistration registration,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
var keyRecords = new List<KeyFingerprintRecord>();
|
var keyRecords = new List<KeyFingerprintRecord>();
|
||||||
|
|
||||||
if (registration.InitialKeys != null)
|
if (registration.InitialKeys != null)
|
||||||
@@ -135,7 +141,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
|||||||
return Task.FromResult(false);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
var updated = current with
|
var updated = current with
|
||||||
{
|
{
|
||||||
Status = IssuerStatus.Revoked,
|
Status = IssuerStatus.Revoked,
|
||||||
@@ -165,7 +171,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
|||||||
throw new InvalidOperationException($"Issuer '{issuerId}' not found");
|
throw new InvalidOperationException($"Issuer '{issuerId}' not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
var newKey = new KeyFingerprintRecord(
|
var newKey = new KeyFingerprintRecord(
|
||||||
Fingerprint: keyRegistration.Fingerprint,
|
Fingerprint: keyRegistration.Fingerprint,
|
||||||
KeyType: keyRegistration.KeyType,
|
KeyType: keyRegistration.KeyType,
|
||||||
@@ -209,7 +215,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
|||||||
return Task.FromResult(false);
|
return Task.FromResult(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = _timeProvider.GetUtcNow();
|
||||||
var revokedKey = keyIndex.k with
|
var revokedKey = keyIndex.k with
|
||||||
{
|
{
|
||||||
Status = KeyFingerprintStatus.Revoked,
|
Status = KeyFingerprintStatus.Revoked,
|
||||||
@@ -284,7 +290,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
|||||||
keyStatus = KeyTrustStatus.Revoked;
|
keyStatus = KeyTrustStatus.Revoked;
|
||||||
warnings.Add($"Key was revoked: {key.RevocationReason}");
|
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;
|
keyStatus = KeyTrustStatus.Expired;
|
||||||
warnings.Add($"Key expired on {key.ExpiresAt.Value:O}");
|
warnings.Add($"Key expired on {key.ExpiresAt.Value:O}");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Zastava.Agent.Configuration;
|
using StellaOps.Zastava.Agent.Configuration;
|
||||||
using StellaOps.Zastava.Core.Contracts;
|
using StellaOps.Zastava.Core.Contracts;
|
||||||
|
|
||||||
@@ -34,15 +35,18 @@ internal sealed class RuntimeEventsClient : IRuntimeEventsClient
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
||||||
private readonly ILogger<RuntimeEventsClient> _logger;
|
private readonly ILogger<RuntimeEventsClient> _logger;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
|
|
||||||
public RuntimeEventsClient(
|
public RuntimeEventsClient(
|
||||||
HttpClient httpClient,
|
HttpClient httpClient,
|
||||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||||
ILogger<RuntimeEventsClient> logger)
|
ILogger<RuntimeEventsClient> logger,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RuntimeEventsSubmitResult> SubmitAsync(
|
public async Task<RuntimeEventsSubmitResult> SubmitAsync(
|
||||||
@@ -63,7 +67,7 @@ internal sealed class RuntimeEventsClient : IRuntimeEventsClient
|
|||||||
{
|
{
|
||||||
var request = new RuntimeEventsSubmitRequest
|
var request = new RuntimeEventsSubmitRequest
|
||||||
{
|
{
|
||||||
BatchId = Guid.NewGuid().ToString("N"),
|
BatchId = _guidProvider.NewGuid().ToString("N"),
|
||||||
Events = envelopes.ToArray()
|
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.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.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.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Zastava.Agent.Configuration;
|
using StellaOps.Zastava.Agent.Configuration;
|
||||||
using StellaOps.Zastava.Agent.Docker;
|
using StellaOps.Zastava.Agent.Docker;
|
||||||
|
|
||||||
@@ -22,16 +23,22 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
|||||||
private readonly IDockerSocketClient _dockerClient;
|
private readonly IDockerSocketClient _dockerClient;
|
||||||
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
||||||
private readonly ILogger<HealthCheckHostedService> _logger;
|
private readonly ILogger<HealthCheckHostedService> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
private HttpListener? _listener;
|
private HttpListener? _listener;
|
||||||
|
|
||||||
public HealthCheckHostedService(
|
public HealthCheckHostedService(
|
||||||
IDockerSocketClient dockerClient,
|
IDockerSocketClient dockerClient,
|
||||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||||
ILogger<HealthCheckHostedService> logger)
|
ILogger<HealthCheckHostedService> logger,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
_dockerClient = dockerClient ?? throw new ArgumentNullException(nameof(dockerClient));
|
_dockerClient = dockerClient ?? throw new ArgumentNullException(nameof(dockerClient));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -184,7 +191,7 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
|||||||
{
|
{
|
||||||
Status = overallHealthy ? "healthy" : "unhealthy",
|
Status = overallHealthy ? "healthy" : "unhealthy",
|
||||||
Checks = checks,
|
Checks = checks,
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
|
|
||||||
return (overallHealthy ? 200 : 503, response);
|
return (overallHealthy ? 200 : 503, response);
|
||||||
@@ -203,7 +210,7 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
|||||||
{
|
{
|
||||||
Status = "ready",
|
Status = "ready",
|
||||||
Message = "Agent ready to process container events",
|
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",
|
Status = "not_ready",
|
||||||
Message = "Docker daemon not reachable",
|
Message = "Docker daemon not reachable",
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = _timeProvider.GetUtcNow()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -220,16 +227,16 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
|||||||
{
|
{
|
||||||
Status = "not_ready",
|
Status = "not_ready",
|
||||||
Message = $"Ready check failed: {ex.Message}",
|
Message = $"Ready check failed: {ex.Message}",
|
||||||
Timestamp = DateTimeOffset.UtcNow
|
Timestamp = _timeProvider.GetUtcNow()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsDirectoryWritable(string path)
|
private bool IsDirectoryWritable(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var testFile = Path.Combine(path, $".healthcheck-{Guid.NewGuid():N}");
|
var testFile = Path.Combine(path, $".healthcheck-{_guidProvider.NewGuid():N}");
|
||||||
File.WriteAllText(testFile, "test");
|
File.WriteAllText(testFile, "test");
|
||||||
File.Delete(testFile);
|
File.Delete(testFile);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Zastava.Agent.Configuration;
|
using StellaOps.Zastava.Agent.Configuration;
|
||||||
using StellaOps.Zastava.Core.Contracts;
|
using StellaOps.Zastava.Core.Contracts;
|
||||||
using StellaOps.Zastava.Core.Serialization;
|
using StellaOps.Zastava.Core.Serialization;
|
||||||
@@ -31,6 +32,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
|||||||
private readonly string _spoolPath;
|
private readonly string _spoolPath;
|
||||||
private readonly ILogger<RuntimeEventBuffer> _logger;
|
private readonly ILogger<RuntimeEventBuffer> _logger;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly IGuidProvider _guidProvider;
|
||||||
private readonly long _maxDiskBytes;
|
private readonly long _maxDiskBytes;
|
||||||
private readonly int _capacity;
|
private readonly int _capacity;
|
||||||
|
|
||||||
@@ -39,11 +41,13 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
|||||||
public RuntimeEventBuffer(
|
public RuntimeEventBuffer(
|
||||||
IOptions<ZastavaAgentOptions> agentOptions,
|
IOptions<ZastavaAgentOptions> agentOptions,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
ILogger<RuntimeEventBuffer> logger)
|
ILogger<RuntimeEventBuffer> logger,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(agentOptions);
|
ArgumentNullException.ThrowIfNull(agentOptions);
|
||||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||||
|
|
||||||
var options = agentOptions.Value ?? throw new ArgumentNullException(nameof(agentOptions));
|
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)
|
private async Task<string> PersistAsync(byte[] payload, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var timestamp = _timeProvider.GetUtcNow().UtcTicks;
|
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);
|
var filePath = Path.Combine(_spoolPath, fileName);
|
||||||
|
|
||||||
Directory.CreateDirectory(_spoolPath);
|
Directory.CreateDirectory(_spoolPath);
|
||||||
|
|||||||
@@ -16,18 +16,21 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
|||||||
private readonly IRuntimeEventsClient _eventsClient;
|
private readonly IRuntimeEventsClient _eventsClient;
|
||||||
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
||||||
private readonly ILogger<RuntimeEventDispatchService> _logger;
|
private readonly ILogger<RuntimeEventDispatchService> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly Random _jitterRandom = new();
|
private readonly Random _jitterRandom = new();
|
||||||
|
|
||||||
public RuntimeEventDispatchService(
|
public RuntimeEventDispatchService(
|
||||||
IRuntimeEventBuffer eventBuffer,
|
IRuntimeEventBuffer eventBuffer,
|
||||||
IRuntimeEventsClient eventsClient,
|
IRuntimeEventsClient eventsClient,
|
||||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||||
ILogger<RuntimeEventDispatchService> logger)
|
ILogger<RuntimeEventDispatchService> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_eventBuffer = eventBuffer ?? throw new ArgumentNullException(nameof(eventBuffer));
|
_eventBuffer = eventBuffer ?? throw new ArgumentNullException(nameof(eventBuffer));
|
||||||
_eventsClient = eventsClient ?? throw new ArgumentNullException(nameof(eventsClient));
|
_eventsClient = eventsClient ?? throw new ArgumentNullException(nameof(eventsClient));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -43,7 +46,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
|||||||
flushInterval);
|
flushInterval);
|
||||||
|
|
||||||
var batch = new List<RuntimeEventBufferItem>(batchSize);
|
var batch = new List<RuntimeEventBufferItem>(batchSize);
|
||||||
var lastFlush = DateTimeOffset.UtcNow;
|
var lastFlush = _timeProvider.GetUtcNow();
|
||||||
var failureCount = 0;
|
var failureCount = 0;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -53,7 +56,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
|||||||
batch.Add(item);
|
batch.Add(item);
|
||||||
|
|
||||||
var shouldFlush = batch.Count >= batchSize ||
|
var shouldFlush = batch.Count >= batchSize ||
|
||||||
(batch.Count > 0 && DateTimeOffset.UtcNow - lastFlush >= flushInterval);
|
(batch.Count > 0 && _timeProvider.GetUtcNow() - lastFlush >= flushInterval);
|
||||||
|
|
||||||
if (shouldFlush)
|
if (shouldFlush)
|
||||||
{
|
{
|
||||||
@@ -68,7 +71,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
batch.Clear();
|
batch.Clear();
|
||||||
lastFlush = DateTimeOffset.UtcNow;
|
lastFlush = _timeProvider.GetUtcNow();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable
|
|||||||
private readonly IRuntimeSignalCollector _signalCollector;
|
private readonly IRuntimeSignalCollector _signalCollector;
|
||||||
private readonly ISignalPublisher _signalPublisher;
|
private readonly ISignalPublisher _signalPublisher;
|
||||||
private readonly EbpfProbeManagerOptions _options;
|
private readonly EbpfProbeManagerOptions _options;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly ConcurrentDictionary<string, SignalCollectionHandle> _activeHandles;
|
private readonly ConcurrentDictionary<string, SignalCollectionHandle> _activeHandles;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
@@ -30,12 +31,14 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable
|
|||||||
ILogger<EbpfProbeManager> logger,
|
ILogger<EbpfProbeManager> logger,
|
||||||
IRuntimeSignalCollector signalCollector,
|
IRuntimeSignalCollector signalCollector,
|
||||||
ISignalPublisher signalPublisher,
|
ISignalPublisher signalPublisher,
|
||||||
IOptions<EbpfProbeManagerOptions> options)
|
IOptions<EbpfProbeManagerOptions> options,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_signalCollector = signalCollector;
|
_signalCollector = signalCollector;
|
||||||
_signalPublisher = signalPublisher;
|
_signalPublisher = signalPublisher;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
_activeHandles = new ConcurrentDictionary<string, SignalCollectionHandle>();
|
_activeHandles = new ConcurrentDictionary<string, SignalCollectionHandle>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +280,7 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable
|
|||||||
Namespace = evt.Labels.GetValueOrDefault("io.kubernetes.pod.namespace"),
|
Namespace = evt.Labels.GetValueOrDefault("io.kubernetes.pod.namespace"),
|
||||||
PodName = evt.Labels.GetValueOrDefault("io.kubernetes.pod.name"),
|
PodName = evt.Labels.GetValueOrDefault("io.kubernetes.pod.name"),
|
||||||
Summary = summary,
|
Summary = summary,
|
||||||
CollectedAt = DateTimeOffset.UtcNow,
|
CollectedAt = _timeProvider.GetUtcNow(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await _signalPublisher.PublishAsync(message, ct);
|
await _signalPublisher.PublishAsync(message, ct);
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
|||||||
private readonly DotNetAssemblyCollector _dotnetCollector;
|
private readonly DotNetAssemblyCollector _dotnetCollector;
|
||||||
private readonly PhpAutoloadCollector _phpCollector;
|
private readonly PhpAutoloadCollector _phpCollector;
|
||||||
private readonly ILogger<ProcSnapshotCollector> _logger;
|
private readonly ILogger<ProcSnapshotCollector> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly string _procRoot;
|
private readonly string _procRoot;
|
||||||
|
|
||||||
public ProcSnapshotCollector(
|
public ProcSnapshotCollector(
|
||||||
IOptions<ZastavaObserverOptions> options,
|
IOptions<ZastavaObserverOptions> options,
|
||||||
|
TimeProvider? timeProvider,
|
||||||
ILoggerFactory loggerFactory)
|
ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
@@ -41,6 +43,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
|||||||
|
|
||||||
_procRoot = options.Value.ProcRootPath;
|
_procRoot = options.Value.ProcRootPath;
|
||||||
_logger = loggerFactory.CreateLogger<ProcSnapshotCollector>();
|
_logger = loggerFactory.CreateLogger<ProcSnapshotCollector>();
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
|
||||||
_javaCollector = new JavaClasspathCollector(
|
_javaCollector = new JavaClasspathCollector(
|
||||||
_procRoot,
|
_procRoot,
|
||||||
@@ -82,7 +85,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
|||||||
|
|
||||||
var document = new ProcSnapshotDocument
|
var document = new ProcSnapshotDocument
|
||||||
{
|
{
|
||||||
Id = $"{tenant}:{imageDigest}:{pid}:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
|
Id = $"{tenant}:{imageDigest}:{pid}:{_timeProvider.GetUtcNow().ToUnixTimeMilliseconds()}",
|
||||||
Tenant = tenant,
|
Tenant = tenant,
|
||||||
ImageDigest = imageDigest,
|
ImageDigest = imageDigest,
|
||||||
ContainerId = container.Id,
|
ContainerId = container.Id,
|
||||||
@@ -91,7 +94,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
|||||||
Classpath = snapshot.Classpath,
|
Classpath = snapshot.Classpath,
|
||||||
LoadedAssemblies = snapshot.LoadedAssemblies,
|
LoadedAssemblies = snapshot.LoadedAssemblies,
|
||||||
AutoloadPaths = snapshot.AutoloadPaths,
|
AutoloadPaths = snapshot.AutoloadPaths,
|
||||||
CapturedAt = DateTimeOffset.UtcNow
|
CapturedAt = _timeProvider.GetUtcNow()
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Runtime.CompilerServices;
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Zastava.Core.Contracts;
|
using StellaOps.Zastava.Core.Contracts;
|
||||||
using StellaOps.Zastava.Core.Serialization;
|
using StellaOps.Zastava.Core.Serialization;
|
||||||
using StellaOps.Zastava.Observer.Configuration;
|
using StellaOps.Zastava.Observer.Configuration;
|
||||||
@@ -32,6 +33,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
|||||||
private readonly string spoolPath;
|
private readonly string spoolPath;
|
||||||
private readonly ILogger<RuntimeEventBuffer> logger;
|
private readonly ILogger<RuntimeEventBuffer> logger;
|
||||||
private readonly TimeProvider timeProvider;
|
private readonly TimeProvider timeProvider;
|
||||||
|
private readonly IGuidProvider guidProvider;
|
||||||
private readonly long maxDiskBytes;
|
private readonly long maxDiskBytes;
|
||||||
|
|
||||||
private long currentBytes;
|
private long currentBytes;
|
||||||
@@ -40,11 +42,13 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
|||||||
public RuntimeEventBuffer(
|
public RuntimeEventBuffer(
|
||||||
IOptions<ZastavaObserverOptions> observerOptions,
|
IOptions<ZastavaObserverOptions> observerOptions,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
ILogger<RuntimeEventBuffer> logger)
|
ILogger<RuntimeEventBuffer> logger,
|
||||||
|
IGuidProvider? guidProvider = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(observerOptions);
|
ArgumentNullException.ThrowIfNull(observerOptions);
|
||||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
this.guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
|
|
||||||
var options = observerOptions.Value ?? throw new ArgumentNullException(nameof(observerOptions));
|
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)
|
private async Task<string> PersistAsync(byte[] payload, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var timestamp = timeProvider.GetUtcNow().UtcTicks;
|
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);
|
var filePath = Path.Combine(spoolPath, fileName);
|
||||||
|
|
||||||
Directory.CreateDirectory(spoolPath);
|
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.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.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.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||||
|
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Protobuf Include="Protos/runtime/v1/runtime.proto" GrpcServices="Client" />
|
<Protobuf Include="Protos/runtime/v1/runtime.proto" GrpcServices="Client" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using StellaOps.Determinism;
|
||||||
using StellaOps.Zastava.Core.Contracts;
|
using StellaOps.Zastava.Core.Contracts;
|
||||||
using StellaOps.Zastava.Observer.Backend;
|
using StellaOps.Zastava.Observer.Backend;
|
||||||
using StellaOps.Zastava.Observer.Configuration;
|
using StellaOps.Zastava.Observer.Configuration;
|
||||||
@@ -17,6 +18,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
|||||||
private readonly IRuntimeFactsClient runtimeFactsClient;
|
private readonly IRuntimeFactsClient runtimeFactsClient;
|
||||||
private readonly IOptionsMonitor<ZastavaObserverOptions> observerOptions;
|
private readonly IOptionsMonitor<ZastavaObserverOptions> observerOptions;
|
||||||
private readonly TimeProvider timeProvider;
|
private readonly TimeProvider timeProvider;
|
||||||
|
private readonly IGuidProvider guidProvider;
|
||||||
private readonly ILogger<RuntimeEventDispatchService> logger;
|
private readonly ILogger<RuntimeEventDispatchService> logger;
|
||||||
|
|
||||||
public RuntimeEventDispatchService(
|
public RuntimeEventDispatchService(
|
||||||
@@ -25,6 +27,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
|||||||
IRuntimeFactsClient runtimeFactsClient,
|
IRuntimeFactsClient runtimeFactsClient,
|
||||||
IOptionsMonitor<ZastavaObserverOptions> observerOptions,
|
IOptionsMonitor<ZastavaObserverOptions> observerOptions,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
|
IGuidProvider? guidProvider,
|
||||||
ILogger<RuntimeEventDispatchService> logger)
|
ILogger<RuntimeEventDispatchService> logger)
|
||||||
{
|
{
|
||||||
this.buffer = buffer ?? throw new ArgumentNullException(nameof(buffer));
|
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.runtimeFactsClient = runtimeFactsClient ?? throw new ArgumentNullException(nameof(runtimeFactsClient));
|
||||||
this.observerOptions = observerOptions ?? throw new ArgumentNullException(nameof(observerOptions));
|
this.observerOptions = observerOptions ?? throw new ArgumentNullException(nameof(observerOptions));
|
||||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||||
|
this.guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +131,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
|||||||
|
|
||||||
var request = new RuntimeEventsIngestRequest
|
var request = new RuntimeEventsIngestRequest
|
||||||
{
|
{
|
||||||
BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{Guid.NewGuid():N}",
|
BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{guidProvider.NewGuid():N}",
|
||||||
Events = envelopes
|
Events = envelopes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ public sealed class WebhookCertificateHealthCheck : IHealthCheck
|
|||||||
{
|
{
|
||||||
private readonly IWebhookCertificateProvider _certificateProvider;
|
private readonly IWebhookCertificateProvider _certificateProvider;
|
||||||
private readonly ILogger<WebhookCertificateHealthCheck> _logger;
|
private readonly ILogger<WebhookCertificateHealthCheck> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly TimeSpan _expiryThreshold = TimeSpan.FromDays(7);
|
private readonly TimeSpan _expiryThreshold = TimeSpan.FromDays(7);
|
||||||
|
|
||||||
public WebhookCertificateHealthCheck(
|
public WebhookCertificateHealthCheck(
|
||||||
IWebhookCertificateProvider certificateProvider,
|
IWebhookCertificateProvider certificateProvider,
|
||||||
ILogger<WebhookCertificateHealthCheck> logger)
|
ILogger<WebhookCertificateHealthCheck> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
{
|
{
|
||||||
_certificateProvider = certificateProvider;
|
_certificateProvider = certificateProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
@@ -22,7 +25,7 @@ public sealed class WebhookCertificateHealthCheck : IHealthCheck
|
|||||||
{
|
{
|
||||||
var certificate = _certificateProvider.GetCertificate();
|
var certificate = _certificateProvider.GetCertificate();
|
||||||
var expires = certificate.NotAfter.ToUniversalTime();
|
var expires = certificate.NotAfter.ToUniversalTime();
|
||||||
var remaining = expires - DateTimeOffset.UtcNow;
|
var remaining = expires - _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
if (remaining <= TimeSpan.Zero)
|
if (remaining <= TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user