feat: add bulk triage view component and related stories

- Exported BulkTriageViewComponent and its related types from findings module.
- Created a new accessibility test suite for score components using axe-core.
- Introduced design tokens for score components to standardize styling.
- Enhanced score breakdown popover for mobile responsiveness with drag handle.
- Added date range selector functionality to score history chart component.
- Implemented unit tests for date range selector in score history chart.
- Created Storybook stories for bulk triage view and score history chart with date range selector.
This commit is contained in:
StellaOps Bot
2025-12-26 01:01:35 +02:00
parent ed3079543c
commit 17613acf57
45 changed files with 9418 additions and 64 deletions

View File

@@ -0,0 +1,386 @@
# Federation Setup and Operations
Per SPRINT_8200_0014_0003.
## Overview
Federation enables multi-site synchronization of canonical advisory data between Concelier instances. Sites can export bundles containing delta changes and import bundles from other sites to maintain synchronized vulnerability intelligence.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Federation Topology │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Site A (HQ) │ │ Site B (Branch) │ │
│ │ │ │ │ │
│ │ ┌────────────┐ │ Export │ ┌────────────┐ │ │
│ │ │ Concelier │──┼──────────►│ │ Concelier │ │ │
│ │ │ │ │ Bundle │ │ │ │ │
│ │ └────────────┘ │ │ └────────────┘ │ │
│ │ │ │ │ │ │ │
│ │ ▼ │ │ ▼ │ │
│ │ ┌────────────┐ │ │ ┌────────────┐ │ │
│ │ │ PostgreSQL │ │ │ │ PostgreSQL │ │ │
│ │ └────────────┘ │ │ └────────────┘ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Site C (Air-Gap)│ │
│ │ │ │
│ │ ┌────────────┐ │ USB/Secure │
│ │ │ Concelier │◄─┼───Transfer │
│ │ │ │ │ │
│ │ └────────────┘ │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Setup
### 1. Enable Federation
Configure federation in `concelier.yaml`:
```yaml
Federation:
Enabled: true
SiteId: "site-us-west-1" # Unique identifier for this site
DefaultCompressionLevel: 3
DefaultMaxItems: 10000
RequireSignature: true
FederationImport:
AllowedSites:
- "site-us-east-1"
- "site-eu-central-1"
MaxBundleSizeBytes: 104857600 # 100 MB
SkipSignatureOnTrustedSites: false
```
### 2. Configure Site Policies
Create site policies for each trusted federation partner:
```bash
# Add trusted site
stella feedser sites add site-us-east-1 \
--display-name "US East Production" \
--enabled
# Configure policy
stella feedser sites policy site-us-east-1 \
--max-bundle-size 100MB \
--allowed-sources nvd,ghsa,debian
```
### 3. Generate Signing Keys
For signed bundles, configure Authority keys:
```bash
# Generate federation signing key
stella authority keys generate \
--name federation-signer \
--algorithm ES256 \
--purpose federation
# Export public key for distribution
stella authority keys export federation-signer --public
```
## Import Operations
### API Import
```
POST /api/v1/federation/import
Content-Type: application/zstd
```
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `dry_run` | bool | false | Validate without importing |
| `skip_signature` | bool | false | Skip signature verification (requires trust) |
| `on_conflict` | enum | prefer_remote | `prefer_remote`, `prefer_local`, `fail` |
| `force` | bool | false | Import even if cursor is not after current |
**Response:**
```json
{
"success": true,
"bundle_hash": "sha256:a1b2c3...",
"imported_cursor": "2025-01-15T10:30:00.000Z#0042",
"counts": {
"canonical_created": 100,
"canonical_updated": 25,
"canonical_skipped": 10,
"edges_added": 200,
"deletions_processed": 5
},
"conflicts": [],
"duration_ms": 1234
}
```
### CLI Import
```bash
# Import from file
stella feedser bundle import ./bundle.zst
# Import with dry run
stella feedser bundle import ./bundle.zst --dry-run
# Import from stdin (for pipes)
cat bundle.zst | stella feedser bundle import -
# Import without signature verification (testing only)
stella feedser bundle import ./bundle.zst --skip-signature
# Force import (override cursor check)
stella feedser bundle import ./bundle.zst --force
```
### Conflict Resolution
When conflicts occur between local and remote values:
| Strategy | Behavior |
|----------|----------|
| `prefer_remote` | Remote value wins (default) |
| `prefer_local` | Local value preserved |
| `fail` | Abort import on first conflict |
Conflicts are logged with full details:
```json
{
"merge_hash": "sha256:abc...",
"field": "severity",
"local_value": "high",
"remote_value": "critical",
"resolution": "prefer_remote"
}
```
## Site Management
### List Sites
```bash
stella feedser sites list
```
Output:
```
SITE ID STATUS LAST SYNC CURSOR
───────────────────────── ──────── ─────────────────── ──────────────────────────
site-us-east-1 enabled 2025-01-15 10:30 2025-01-15T10:30:00Z#0042
site-eu-central-1 enabled 2025-01-15 09:15 2025-01-15T09:15:00Z#0038
site-asia-pacific-1 disabled never -
```
### View Site History
```bash
stella feedser sites history site-us-east-1 --limit 10
```
### Update Site Policy
```bash
stella feedser sites policy site-us-east-1 \
--enabled false # Disable imports from this site
```
## Air-Gap Operations
For sites without network connectivity:
### Export for Transfer
```bash
# On connected site
stella feedser bundle export \
-c "2025-01-14T00:00:00Z#0000" \
-o ./delta-2025-01-15.zst
# Transfer via USB/secure media
```
### Import on Air-Gap Site
```bash
# On air-gapped site
stella feedser bundle import ./delta-2025-01-15.zst
# Verify import
stella feedser sites list
```
### Full Sync Workflow
1. **Initial Sync:**
```bash
# Export full dataset
stella feedser bundle export -o ./full-sync.zst
```
2. **Transfer to air-gap site**
3. **Import on air-gap:**
```bash
stella feedser bundle import ./full-sync.zst
```
4. **Subsequent Delta Syncs:**
```bash
# Get current cursor from air-gap site
stella feedser sites list # Note the cursor
# On connected site, export delta
stella feedser bundle export -c "{cursor}" -o ./delta.zst
# Transfer and import on air-gap
```
## Verification
### Validate Bundle Without Import
```bash
stella feedser bundle validate ./bundle.zst
```
Output:
```
Bundle: bundle.zst
Version: feedser-bundle/1.0
Site: site-us-east-1
Cursor: 2025-01-15T10:30:00.000Z#0042
Counts:
Canonicals: 1,234
Edges: 3,456
Deletions: 12
Total: 4,702
Verification:
Hash: ✓ Valid
Signature: ✓ Valid (key: sha256:abc...)
Format: ✓ Valid
Ready for import.
```
### Preview Import Impact
```bash
stella feedser bundle import ./bundle.zst --dry-run --json
```
## Monitoring
### Sync Status Endpoint
```
GET /api/v1/federation/sync/status
```
Response:
```json
{
"sites": [
{
"site_id": "site-us-east-1",
"enabled": true,
"last_sync_at": "2025-01-15T10:30:00Z",
"last_cursor": "2025-01-15T10:30:00.000Z#0042",
"bundles_imported": 156,
"total_items_imported": 45678
}
],
"local_cursor": "2025-01-15T10:35:00.000Z#0044"
}
```
### Event Stream
Import events are published to the `canonical-imported` stream:
```json
{
"canonical_id": "uuid",
"cve": "CVE-2024-1234",
"affects_key": "pkg:npm/express@4.0.0",
"merge_hash": "sha256:...",
"action": "Created",
"bundle_hash": "sha256:...",
"site_id": "site-us-east-1",
"imported_at": "2025-01-15T10:30:15Z"
}
```
### Cache Invalidation
After import, cache indexes are automatically updated:
- PURL index updated for affected packages
- CVE index updated for vulnerability lookups
- Existing cache entries invalidated for refresh
## Troubleshooting
### Common Issues
| Issue | Cause | Solution |
|-------|-------|----------|
| "Cursor not after current" | Bundle is stale | Use `--force` or export newer bundle |
| "Signature verification failed" | Key mismatch | Verify signing key is trusted |
| "Site not allowed" | Policy restriction | Add site to `AllowedSites` config |
| "Bundle too large" | Size limit exceeded | Increase `MaxBundleSizeBytes` or export smaller delta |
### Debug Logging
Enable verbose logging for federation operations:
```yaml
Logging:
LogLevel:
StellaOps.Concelier.Federation: Debug
```
### Verify Sync State
```bash
# Check local vs remote cursor
stella feedser sites status site-us-east-1
# List recent imports
stella feedser sites history site-us-east-1 --limit 5
# Verify specific canonical was imported
stella feedser canonical get sha256:mergehash...
```
## Best Practices
1. **Regular Sync Schedule:** Configure automated delta exports/imports on a schedule (e.g., hourly)
2. **Monitor Cursor Drift:** Alert if cursor falls too far behind
3. **Verify Signatures:** Only disable signature verification in development
4. **Size Bundles Appropriately:** For large deltas, split into multiple bundles
5. **Test Import Before Production:** Use `--dry-run` to validate bundles
6. **Maintain Key Trust:** Regularly rotate and verify federation signing keys
7. **Document Site Policies:** Keep a registry of trusted sites and their policies

View File

@@ -0,0 +1,332 @@
# Federation Setup and Operations Guide
This guide covers the setup and operation of StellaOps federation for multi-site vulnerability data synchronization.
## Overview
Federation enables secure, cursor-based synchronization of canonical vulnerability advisories between StellaOps sites. It supports:
- **Delta exports**: Only changed records since the last cursor are included
- **Air-gap transfers**: Bundles can be written to files for offline transfer
- **Multi-site topology**: Multiple sites can synchronize independently
- **Cryptographic verification**: DSSE signatures ensure bundle authenticity
## Architecture
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Site A │────▶│ Bundle │────▶│ Site B │
│ (Export) │ │ (.zst) │ │ (Import) │
└─────────────┘ └─────────────┘ └─────────────┘
┌───────────┐
│ Site C │
│ (Import) │
└───────────┘
```
## Bundle Format
Federation bundles are ZST-compressed TAR archives containing:
| File | Description |
|------|-------------|
| `MANIFEST.json` | Bundle metadata, cursor, counts, hash |
| `canonicals.ndjson` | Canonical advisories (one per line) |
| `edges.ndjson` | Source edges linking advisories to sources |
| `deletions.ndjson` | Withdrawn/deleted advisory IDs |
| `SIGNATURE.json` | Optional DSSE signature envelope |
## Configuration
### Export Site Configuration
```yaml
# concelier.yaml
federation:
enabled: true
site_id: "us-west-1" # Unique site identifier
export:
enabled: true
default_compression_level: 3 # ZST level (1-19)
sign_bundles: true # Sign exported bundles
max_items_per_bundle: 10000 # Maximum items per export
```
### Import Site Configuration
```yaml
# concelier.yaml
federation:
enabled: true
site_id: "eu-central-1"
import:
enabled: true
skip_signature_verification: false # NEVER set true in production
allowed_sites: # Trusted site IDs
- "us-west-1"
- "ap-south-1"
conflict_resolution: "prefer_remote" # prefer_remote | prefer_local | fail
force_cursor_validation: true # Reject out-of-order imports
```
## API Endpoints
### Export Endpoints
```bash
# Export delta bundle since cursor
GET /api/v1/federation/export?since_cursor={cursor}
# Preview export (counts only)
GET /api/v1/federation/export/preview?since_cursor={cursor}
# Get federation status
GET /api/v1/federation/status
```
### Import Endpoints
```bash
# Import bundle
POST /api/v1/federation/import
Content-Type: application/zstd
# Validate bundle without importing
POST /api/v1/federation/validate
Content-Type: application/zstd
# List federated sites
GET /api/v1/federation/sites
# Update site policy
PUT /api/v1/federation/sites/{site_id}/policy
```
## CLI Commands
### Export Operations
```bash
# Export full bundle (no cursor = all data)
feedser bundle export --output bundle.zst
# Export delta since last cursor
feedser bundle export --since-cursor "2025-01-15T10:00:00Z#0001" --output delta.zst
# Preview export without creating bundle
feedser bundle preview --since-cursor "2025-01-15T10:00:00Z#0001"
# Export without signing (testing only)
feedser bundle export --no-sign --output unsigned.zst
```
### Import Operations
```bash
# Import bundle
feedser bundle import bundle.zst
# Dry run (validate without importing)
feedser bundle import bundle.zst --dry-run
# Import from stdin (pipe)
cat bundle.zst | feedser bundle import -
# Force import (skip cursor validation)
feedser bundle import bundle.zst --force
```
### Site Management
```bash
# List federated sites
feedser sites list
# Show site details
feedser sites show us-west-1
# Enable/disable site
feedser sites enable ap-south-1
feedser sites disable ap-south-1
```
## Cursor Format
Cursors use ISO-8601 timestamp with sequence number:
```
{ISO-8601 timestamp}#{sequence number}
Examples:
2025-01-15T10:00:00.000Z#0001
2025-01-15T10:00:00.000Z#0002
```
- Cursors are site-specific (each site maintains independent cursors)
- Sequence numbers distinguish concurrent exports
- Cursors are monotonically increasing within a site
## Air-Gap Transfer Workflow
For environments without network connectivity:
```bash
# On Source Site (connected to authority)
feedser bundle export --since-cursor "$LAST_CURSOR" --output /media/usb/bundle.zst
feedser bundle preview --since-cursor "$LAST_CURSOR" > /media/usb/manifest.txt
# Transfer media to target site...
# On Target Site (air-gapped)
feedser bundle import /media/usb/bundle.zst --dry-run # Validate first
feedser bundle import /media/usb/bundle.zst # Import
```
## Multi-Site Synchronization
### Hub-and-Spoke Topology
```
┌─────────────┐
│ Hub Site │
│ (Primary) │
└──────┬──────┘
┌──────────┼──────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Site A │ │ Site B │ │ Site C │
│ (Spoke) │ │ (Spoke) │ │ (Spoke) │
└──────────┘ └──────────┘ └──────────┘
```
### Mesh Topology
Each site can import from multiple sources:
```yaml
federation:
import:
allowed_sites:
- "hub-primary"
- "hub-secondary" # Redundancy
```
## Merge Behavior
### Conflict Resolution
When importing, conflicts are resolved based on configuration:
| Strategy | Behavior |
|----------|----------|
| `prefer_remote` | Remote (bundle) value wins (default) |
| `prefer_local` | Local value preserved |
| `fail` | Import aborts on any conflict |
### Merge Actions
| Action | Description |
|--------|-------------|
| `Created` | New canonical added |
| `Updated` | Existing canonical updated |
| `Skipped` | No change needed (identical) |
## Verification
### Hash Verification
Bundle hash is computed over compressed content:
```
SHA256(compressed bundle content)
```
### Signature Verification
DSSE envelope contains:
```json
{
"payloadType": "application/stellaops.federation.bundle+json",
"payload": "base64(bundle_hash + site_id + cursor)",
"signatures": [
{
"keyId": "signing-key-001",
"algorithm": "ES256",
"signature": "base64(signature)"
}
]
}
```
## Monitoring
### Key Metrics
- `federation_export_duration_seconds` - Export time
- `federation_import_duration_seconds` - Import time
- `federation_bundle_size_bytes` - Bundle sizes
- `federation_items_processed_total` - Items processed by type
- `federation_conflicts_total` - Merge conflicts encountered
### Health Checks
```bash
# Check federation status
curl http://localhost:5000/api/v1/federation/status
# Response
{
"site_id": "us-west-1",
"export_enabled": true,
"import_enabled": true,
"last_export": "2025-01-15T10:00:00Z",
"last_import": "2025-01-15T09:30:00Z",
"sites_synced": 2
}
```
## Troubleshooting
### Common Issues
**Import fails with "cursor validation failed"**
- Bundle cursor is not after current site cursor
- Use `--force` to override (not recommended)
- Check if bundle was already imported
**Signature verification failed**
- Signing key not trusted on target site
- Key expired or revoked
- Use `--skip-signature` for testing only
**Large bundle timeout**
- Increase `federation.export.timeout`
- Use smaller `max_items_per_bundle`
- Stream directly to file
### Debug Logging
```yaml
logging:
level:
StellaOps.Concelier.Federation: Debug
```
## Security Considerations
1. **Never skip signature verification in production**
2. **Validate allowed_sites whitelist**
3. **Use TLS for API endpoints**
4. **Rotate signing keys periodically**
5. **Audit import events**
6. **Monitor for duplicate bundle imports**
## Related Documentation
- [Bundle Export Format](federation-bundle-export.md)
- [Sync Ledger Schema](../db/sync-ledger.md)
- [Signing Configuration](../security/signing.md)

View File

@@ -0,0 +1,247 @@
# Interest Scoring
Per SPRINT_8200_0013_0002.
## Overview
Interest scoring learns which advisories matter to your organization by analyzing SBOM intersections, reachability data, VEX status, and deployment signals. High-interest advisories are prioritized and cached, while low-interest ones are degraded to lightweight stubs to save resources.
## Score Components
The interest score is computed from weighted factors:
| Factor | Weight | Description |
|--------|--------|-------------|
| `in_sbom` | 0.4 | Advisory affects a component in registered SBOMs |
| `reachable` | 0.25 | Affected code is reachable in call graph |
| `deployed` | 0.15 | Component is deployed in production |
| `no_vex_na` | 0.1 | No VEX "not_affected" statement exists |
| `recent` | 0.1 | Advisory is recent (age decay applied) |
### Score Calculation
```
score = (in_sbom × 0.4) + (reachable × 0.25) + (deployed × 0.15)
+ (no_vex_na × 0.1) + (recent × 0.1)
```
Each factor is binary (0.0 or 1.0), except `recent` which decays over time.
## Configuration
Configure in `concelier.yaml`:
```yaml
InterestScoring:
Enabled: true
# Factor weights (must sum to 1.0)
Weights:
InSbom: 0.4
Reachable: 0.25
Deployed: 0.15
NoVexNa: 0.1
Recent: 0.1
# Age decay for recent factor
RecentDecay:
HalfLifeDays: 90
MinScore: 0.1
# Stub degradation policy
Degradation:
Enabled: true
ThresholdScore: 0.1
GracePeriodDays: 30
RetainCve: true # Keep CVE ID even in stub form
# Recalculation job
RecalculationJob:
Enabled: true
IncrementalIntervalMinutes: 15
FullRecalculationHour: 3 # 3 AM daily
```
## API Endpoints
### Get Score
```
GET /api/v1/canonical/{id}/score
```
Response:
```json
{
"canonical_id": "uuid",
"score": 0.85,
"tier": "high",
"reasons": [
{ "factor": "in_sbom", "value": 1.0, "weight": 0.4, "contribution": 0.4 },
{ "factor": "reachable", "value": 1.0, "weight": 0.25, "contribution": 0.25 },
{ "factor": "deployed", "value": 1.0, "weight": 0.15, "contribution": 0.15 },
{ "factor": "no_vex_na", "value": 0.0, "weight": 0.1, "contribution": 0.0 },
{ "factor": "recent", "value": 0.5, "weight": 0.1, "contribution": 0.05 }
],
"computed_at": "2025-01-15T10:30:00Z",
"last_seen_in_build": "uuid"
}
```
### Trigger Recalculation (Admin)
```
POST /api/v1/scores/recalculate
```
Request:
```json
{
"mode": "incremental", // or "full"
"canonical_ids": null // null = all, or specific IDs
}
```
Response:
```json
{
"job_id": "uuid",
"mode": "incremental",
"canonicals_queued": 1234,
"started_at": "2025-01-15T10:30:00Z"
}
```
## Score Tiers
| Tier | Score Range | Cache TTL | Behavior |
|------|-------------|-----------|----------|
| High | >= 0.7 | 24 hours | Full advisory cached, prioritized in queries |
| Medium | 0.3 - 0.7 | 4 hours | Full advisory cached |
| Low | 0.1 - 0.3 | 1 hour | Full advisory, eligible for degradation |
| Negligible | < 0.1 | none | Degraded to stub after grace period |
## Stub Degradation
Low-interest advisories are degraded to lightweight stubs to save storage:
### Full Advisory vs Stub
| Field | Full | Stub |
|-------|------|------|
| id | | |
| cve | | |
| affects_key | | |
| merge_hash | | |
| severity | | |
| title | | |
| summary | | |
| references | | |
| weaknesses | | |
| source_edges | | |
### Restoration
Stubs are restored to full advisories when:
- Interest score increases above threshold
- Advisory is directly queried by ID
- Advisory appears in scan results
```csharp
// Automatic restoration
await scoringService.RestoreFromStubAsync(canonicalId, ct);
```
## Integration Points
### SBOM Registration
When an SBOM is registered, interest scores are updated:
```
POST /api/v1/learn/sbom → Triggers score recalculation
```
### Scan Events
Subscribe to `ScanCompleted` events:
```csharp
// In event handler
public async Task HandleScanCompletedAsync(ScanCompletedEvent evt)
{
await sbomService.LearnFromScanAsync(evt.SbomDigest, ct);
// This triggers interest score updates for affected advisories
}
```
### VEX Updates
VEX imports trigger score recalculation:
```csharp
// In VEX ingestion pipeline
await interestService.RecalculateForCanonicalAsync(canonicalId, ct);
```
## CLI Commands
```bash
# View score for advisory
stella scores get sha256:mergehash...
# Trigger recalculation
stella scores recalculate --mode incremental
# Full recalculation
stella scores recalculate --mode full
# List high-interest advisories
stella scores list --tier high --limit 100
# Force restore from stub
stella scores restore sha256:mergehash...
```
## Monitoring
### Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `interest_score_computed_total` | Counter | Scores computed |
| `interest_score_distribution` | Histogram | Score distribution |
| `interest_stubs_created_total` | Counter | Advisories degraded to stubs |
| `interest_stubs_restored_total` | Counter | Stubs restored to full |
| `interest_job_duration_seconds` | Histogram | Recalculation job duration |
### Alerts
```yaml
# Example Prometheus alert
- alert: InterestScoreJobStale
expr: time() - interest_score_last_full_recalc > 172800
for: 1h
labels:
severity: warning
annotations:
summary: "Interest score full recalculation hasn't run in 2 days"
```
## Database Schema
```sql
CREATE TABLE vuln.interest_score (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
canonical_id UUID NOT NULL REFERENCES vuln.advisory_canonical(id),
score NUMERIC(3,2) NOT NULL CHECK (score >= 0 AND score <= 1),
reasons JSONB NOT NULL DEFAULT '[]',
last_seen_in_build UUID,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_interest_score_canonical UNIQUE (canonical_id)
);
CREATE INDEX idx_interest_score_score ON vuln.interest_score(score DESC);
CREATE INDEX idx_interest_score_high ON vuln.interest_score(canonical_id)
WHERE score >= 0.7;
```

View File

@@ -0,0 +1,315 @@
# Valkey Advisory Cache
Per SPRINT_8200_0013_0001.
## Overview
The Valkey Advisory Cache provides sub-20ms read latency for canonical advisory lookups by caching advisory data and maintaining fast-path indexes. The cache integrates with the Concelier CanonicalAdvisoryService as a read-through cache with automatic population and invalidation.
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Advisory Cache Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────┐ miss ┌───────────┐ fetch ┌───────────────┐ │
│ │ Client │ ─────────► │ Valkey │ ──────────► │ PostgreSQL │ │
│ │ Request │ │ Cache │ │ Canonical │ │
│ └───────────┘ └───────────┘ │ Store │ │
│ ▲ │ └───────────────┘ │
│ │ │ hit │ │
│ │ ▼ │ │
│ └──────────────── (< 20ms) ◄────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Configuration
Configure in `concelier.yaml`:
```yaml
ConcelierCache:
# Valkey connection settings
ConnectionString: "localhost:6379,abortConnect=false,connectTimeout=5000"
Database: 0
InstanceName: "concelier:"
# TTL settings by interest score tier
TtlPolicy:
HighInterestTtlHours: 24 # Interest score >= 0.7
MediumInterestTtlHours: 4 # Interest score 0.3 - 0.7
LowInterestTtlHours: 1 # Interest score < 0.3
DefaultTtlHours: 2
# Index settings
HotSetMaxSize: 10000 # Max entries in rank:hot
EnablePurlIndex: true
EnableCveIndex: true
# Connection pool settings
PoolSize: 20
ConnectRetryCount: 3
ReconnectRetryDelayMs: 1000
# Fallback behavior when Valkey unavailable
FallbackToPostgres: true
HealthCheckIntervalSeconds: 30
```
## Key Schema
### Advisory Entry
**Key:** `advisory:{merge_hash}`
**Value:** JSON-serialized `CanonicalAdvisory`
**TTL:** Based on interest score tier
```json
{
"id": "uuid",
"cve": "CVE-2024-1234",
"affects_key": "pkg:npm/express@4.0.0",
"merge_hash": "sha256:a1b2c3...",
"severity": "high",
"interest_score": 0.85,
"title": "...",
"updated_at": "2025-01-15T10:30:00Z"
}
```
### Hot Set (Sorted Set)
**Key:** `rank:hot`
**Score:** Interest score (0.0 - 1.0)
**Member:** merge_hash
Stores top advisories by interest score for quick access.
### PURL Index (Set)
**Key:** `by:purl:{normalized_purl}`
**Members:** Set of merge_hash values
Maps package URLs to affected advisories.
Example: `by:purl:pkg:npm/express@4.0.0``{sha256:a1b2c3..., sha256:d4e5f6...}`
### CVE Index (Set)
**Key:** `by:cve:{cve_id}`
**Members:** Set of merge_hash values
Maps CVE IDs to canonical advisories.
Example: `by:cve:cve-2024-1234``{sha256:a1b2c3...}`
## Operations
### Get Advisory
```csharp
// Service interface
public interface IAdvisoryCacheService
{
Task<CanonicalAdvisory?> GetAsync(string mergeHash, CancellationToken ct = default);
Task SetAsync(CanonicalAdvisory advisory, CancellationToken ct = default);
Task InvalidateAsync(string mergeHash, CancellationToken ct = default);
Task<IReadOnlyList<CanonicalAdvisory>> GetByPurlAsync(string purl, CancellationToken ct = default);
Task<IReadOnlyList<CanonicalAdvisory>> GetHotAsync(int count = 100, CancellationToken ct = default);
Task IndexPurlAsync(string purl, string mergeHash, CancellationToken ct = default);
Task IndexCveAsync(string cve, string mergeHash, CancellationToken ct = default);
}
```
### Read-Through Cache
```
1. GetAsync(mergeHash) called
2. Check Valkey: GET advisory:{mergeHash}
└─ Hit: deserialize and return
└─ Miss: fetch from PostgreSQL, cache result, return
```
### Cache Population
Advisories are cached when:
- First read (read-through)
- Ingested from source connectors
- Imported from federation bundles
- Updated by merge operations
### Cache Invalidation
Invalidation occurs when:
- Advisory is updated (re-merge with new data)
- Advisory is withdrawn
- Manual cache flush requested
```csharp
// Invalidate single advisory
await cacheService.InvalidateAsync(mergeHash, ct);
// Invalidate multiple (e.g., after bulk import)
await cacheService.InvalidateManyAsync(mergeHashes, ct);
```
## TTL Policy
Interest score determines TTL tier:
| Interest Score | TTL | Rationale |
|----------------|-----|-----------|
| >= 0.7 (High) | 24 hours | Hot advisories: likely to be queried frequently |
| 0.3 - 0.7 (Medium) | 4 hours | Moderate interest: balance between freshness and cache hits |
| < 0.3 (Low) | 1 hour | Low interest: evict quickly to save memory |
TTL is set when advisory is cached:
```csharp
var ttl = ttlPolicy.GetTtl(advisory.InterestScore);
await cache.SetAsync(key, advisory, ttl, ct);
```
## Monitoring
### Metrics
| Metric | Type | Description |
|--------|------|-------------|
| `concelier_cache_hits_total` | Counter | Total cache hits |
| `concelier_cache_misses_total` | Counter | Total cache misses |
| `concelier_cache_hit_rate` | Gauge | Hit rate (hits / total) |
| `concelier_cache_latency_ms` | Histogram | Cache operation latency |
| `concelier_cache_size_bytes` | Gauge | Estimated cache memory usage |
| `concelier_cache_hot_set_size` | Gauge | Entries in rank:hot |
### OpenTelemetry Spans
Cache operations emit spans:
```
concelier.cache.get
├── cache.key: "advisory:sha256:..."
├── cache.hit: true/false
└── cache.latency_ms: 2.5
concelier.cache.set
├── cache.key: "advisory:sha256:..."
└── cache.ttl_hours: 24
```
### Health Check
```
GET /health/cache
```
Response:
```json
{
"status": "healthy",
"valkey_connected": true,
"latency_ms": 1.2,
"hot_set_size": 8542,
"hit_rate_1h": 0.87
}
```
## Performance
### Benchmarks
| Operation | p50 | p95 | p99 |
|-----------|-----|-----|-----|
| GetAsync (hit) | 1.2ms | 3.5ms | 8.0ms |
| GetAsync (miss + populate) | 12ms | 25ms | 45ms |
| SetAsync | 1.5ms | 4.0ms | 9.0ms |
| GetByPurlAsync | 2.5ms | 6.0ms | 15ms |
| GetHotAsync(100) | 3.0ms | 8.0ms | 18ms |
### Optimization Tips
1. **Connection Pooling:** Use shared multiplexer with `PoolSize: 20`
2. **Pipeline Reads:** For bulk operations, use pipelining:
```csharp
var batch = cache.CreateBatch();
foreach (var hash in mergeHashes)
tasks.Add(batch.GetAsync(hash));
batch.Execute();
```
3. **Hot Set Preload:** Run warmup job on startup to preload hot set
4. **Compression:** Enable Valkey LZF compression for large advisories
## Fallback Behavior
When Valkey is unavailable:
1. **FallbackToPostgres: true** (default)
- All reads go directly to PostgreSQL
- Performance degrades but system remains operational
- Reconnection attempts continue in background
2. **FallbackToPostgres: false**
- Cache misses return null/empty
- Only cached data is accessible
- Use for strict latency requirements
## Troubleshooting
### Common Issues
| Issue | Cause | Solution |
|-------|-------|----------|
| High miss rate | Cache cold / insufficient TTL | Run warmup job, increase TTLs |
| Latency spikes | Connection exhaustion | Increase PoolSize |
| Memory pressure | Too many cached advisories | Reduce HotSetMaxSize, lower TTLs |
| Index stale | Invalidation not triggered | Check event handlers, verify IndexPurlAsync calls |
### Debug Commands
```bash
# Check cache stats
stella cache stats
# View hot set
stella cache list-hot --limit 10
# Check specific advisory
stella cache get sha256:mergehash...
# Flush cache
stella cache flush --confirm
# Check PURL index
stella cache lookup-purl pkg:npm/express@4.0.0
```
### Valkey CLI
```bash
# Connect to Valkey
redis-cli -h localhost -p 6379
# Check memory usage
INFO memory
# List hot set entries
ZRANGE rank:hot 0 9 WITHSCORES
# Check PURL index
SMEMBERS by:purl:pkg:npm/express@4.0.0
# Get advisory
GET advisory:sha256:a1b2c3...
```

View File

@@ -0,0 +1,345 @@
# SBOM Learning API
Per SPRINT_8200_0013_0003.
## Overview
The SBOM Learning API enables Concelier to learn which advisories are relevant to your organization by registering SBOMs from scanned images. When an SBOM is registered, Concelier matches its components against the canonical advisory database and updates interest scores accordingly.
## Flow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ SBOM Learning Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ scan ┌─────────┐ SBOM ┌───────────┐ │
│ │ Image │ ──────────► │ Scanner │ ─────────► │ Concelier │ │
│ │ │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────┬─────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ SBOM Registration │ │
│ │ ┌───────────────┐ │ │
│ │ │ Extract PURLs │ │ │
│ │ └───────┬───────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────┐ │ │
│ │ │ Match Advs │ │ │
│ │ └───────┬───────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────┐ │ │
│ │ │ Update Scores │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## API Endpoints
### Register SBOM
```
POST /api/v1/learn/sbom
Content-Type: application/vnd.cyclonedx+json
```
or
```
POST /api/v1/learn/sbom
Content-Type: application/spdx+json
```
**Request Body:** CycloneDX or SPDX SBOM document
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `artifact_id` | string | required | Image digest or artifact identifier |
| `update_scores` | bool | true | Trigger immediate score recalculation |
| `include_reachability` | bool | true | Include reachability data in matching |
**Response:**
```json
{
"sbom_id": "uuid",
"sbom_digest": "sha256:abc123...",
"artifact_id": "sha256:image...",
"component_count": 234,
"matched_advisories": 15,
"scores_updated": true,
"registered_at": "2025-01-15T10:30:00Z"
}
```
### Get Affected Advisories
```
GET /api/v1/sboms/{digest}/affected
```
**Response:**
```json
{
"sbom_digest": "sha256:abc123...",
"artifact_id": "sha256:image...",
"matched_advisories": [
{
"canonical_id": "uuid",
"cve": "CVE-2024-1234",
"severity": "high",
"interest_score": 0.85,
"matched_component": "pkg:npm/express@4.17.1",
"is_reachable": true
},
{
"canonical_id": "uuid",
"cve": "CVE-2024-5678",
"severity": "medium",
"interest_score": 0.65,
"matched_component": "pkg:npm/lodash@4.17.20",
"is_reachable": false
}
],
"total_count": 15,
"last_matched_at": "2025-01-15T10:30:00Z"
}
```
### List Registered SBOMs
```
GET /api/v1/sboms
```
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `artifact_id` | string | null | Filter by artifact |
| `since` | datetime | null | Only SBOMs registered after this time |
| `limit` | int | 100 | Max results |
| `cursor` | string | null | Pagination cursor |
**Response:**
```json
{
"sboms": [
{
"id": "uuid",
"artifact_id": "sha256:image...",
"sbom_digest": "sha256:abc123...",
"sbom_format": "cyclonedx",
"component_count": 234,
"matched_advisory_count": 15,
"registered_at": "2025-01-15T10:30:00Z"
}
],
"total_count": 42,
"next_cursor": "cursor..."
}
```
### Unregister SBOM
```
DELETE /api/v1/sboms/{digest}
```
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `update_scores` | bool | true | Recalculate scores after removal |
## Matching Algorithm
### PURL Matching
1. **Exact Match:** `pkg:npm/express@4.17.1` matches advisories affecting exactly that version
2. **Range Match:** Uses semantic version ranges from advisory affects_key
3. **Namespace Normalization:** `@scope/pkg` normalized for comparison
### CPE Matching
For OS packages (rpm, deb):
1. Extract CPE from SBOM
2. Match against advisory CPE patterns
3. Apply distro-specific version logic (NEVRA/EVR)
### Reachability Integration
When `include_reachability=true`:
1. Query Scanner call graph data for matched components
2. Mark `is_reachable` based on path from entry point
3. Factor into interest score calculation
## Events
### SbomLearned
Published when SBOM is registered:
```json
{
"event_type": "sbom_learned",
"sbom_id": "uuid",
"sbom_digest": "sha256:...",
"artifact_id": "sha256:...",
"component_count": 234,
"matched_advisory_count": 15,
"timestamp": "2025-01-15T10:30:00Z"
}
```
### ScoresUpdated
Published after batch score update:
```json
{
"event_type": "scores_updated",
"trigger": "sbom_registration",
"sbom_digest": "sha256:...",
"advisories_updated": 15,
"timestamp": "2025-01-15T10:30:05Z"
}
```
## Auto-Learning
Subscribe to Scanner events for automatic SBOM registration:
### Configuration
```yaml
SbomIntegration:
AutoLearn:
Enabled: true
SubscribeToScanEvents: true
EventSource: "scanner:scan_completed"
Matching:
EnablePurl: true
EnableCpe: true
IncludeReachability: true
ScoreUpdate:
BatchSize: 1000
DelaySeconds: 5 # Debounce rapid updates
```
### Event Handler
```csharp
// Automatic registration on scan completion
public class ScanCompletedHandler : IEventHandler<ScanCompletedEvent>
{
public async Task HandleAsync(ScanCompletedEvent evt, CancellationToken ct)
{
await _sbomService.LearnFromScanAsync(
artifactId: evt.ImageDigest,
sbomDigest: evt.SbomDigest,
sbomContent: evt.SbomContent,
cancellationToken: ct);
}
}
```
## CLI Commands
```bash
# Register SBOM from file
stella learn sbom --file ./sbom.json --artifact sha256:image...
# Register from stdin
cat sbom.json | stella learn sbom --artifact sha256:image...
# List affected advisories
stella sbom affected sha256:sbomdigest...
# List registered SBOMs
stella sbom list --limit 20
# Unregister SBOM
stella sbom unregister sha256:sbomdigest...
```
## Integration Examples
### CI/CD Pipeline
```yaml
# Example GitHub Actions workflow
- name: Scan image
run: stella scan image myapp:latest -o sbom.json
- name: Register SBOM
run: stella learn sbom --file sbom.json --artifact ${{ steps.build.outputs.digest }}
- name: Check for critical advisories
run: |
AFFECTED=$(stella sbom affected ${{ steps.sbom.outputs.digest }} --severity critical --count)
if [ "$AFFECTED" -gt 0 ]; then
echo "::error::Found $AFFECTED critical advisories"
exit 1
fi
```
### Programmatic Registration
```csharp
// Register SBOM from code
var result = await sbomService.RegisterSbomAsync(
artifactId: imageDigest,
sbomContent: sbomJson,
format: SbomFormat.CycloneDX,
options: new RegistrationOptions
{
UpdateScores = true,
IncludeReachability = true
},
cancellationToken);
// Get affected advisories
var affected = await sbomService.GetAffectedAdvisoriesAsync(
sbomDigest: result.SbomDigest,
cancellationToken);
```
## Database Schema
```sql
CREATE TABLE vuln.sbom_registry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
artifact_id TEXT NOT NULL,
sbom_digest TEXT NOT NULL,
sbom_format TEXT NOT NULL,
component_count INT NOT NULL DEFAULT 0,
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_matched_at TIMESTAMPTZ,
CONSTRAINT uq_sbom_registry_digest UNIQUE (tenant_id, sbom_digest)
);
CREATE TABLE vuln.sbom_canonical_match (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sbom_id UUID NOT NULL REFERENCES vuln.sbom_registry(id),
canonical_id UUID NOT NULL REFERENCES vuln.advisory_canonical(id),
matched_purl TEXT NOT NULL,
is_reachable BOOLEAN NOT NULL DEFAULT false,
matched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_sbom_canonical_match UNIQUE (sbom_id, canonical_id)
);
```