feat: Add VEX Status Chip component and integration tests for reachability drift detection
- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
# Staleness & Time Anchor Contract v1.0.0
|
||||
|
||||
**Status:** APPROVED
|
||||
**Version:** 1.0.0
|
||||
**Effective:** 2025-12-19
|
||||
**Owner:** AirGap Guild + Findings Ledger Guild
|
||||
**Sprint:** SPRINT_0510_0001_0001 (unblocks LEDGER-AIRGAP-56-002, LEDGER-AIRGAP-57-001)
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This contract defines how air-gapped StellaOps installations maintain trusted time references, calculate data staleness, and enforce freshness policies. It enables deterministic vulnerability triage even when disconnected from external time sources.
|
||||
|
||||
## 2. Schema References
|
||||
|
||||
| Schema | Location |
|
||||
|--------|----------|
|
||||
| Time Anchor | `docs/schemas/time-anchor.schema.json` |
|
||||
| Ledger Staleness | `docs/schemas/ledger-airgap-staleness.schema.json` |
|
||||
| Sealed Mode | `docs/schemas/sealed-mode.schema.json` |
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Air-Gapped Environment │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Mirror │───▶│ AirGap │───▶│ AirGap Time │ │
|
||||
│ │ Bundle │ │ Controller │ │ Service │ │
|
||||
│ │ (time anchor)│ └──────────────┘ └──────────────────────┘ │
|
||||
│ └──────────────┘ │ │ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────┐ │
|
||||
│ │ Staleness Calculator │ │
|
||||
│ │ (drift, budgets, validation) │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┴─────────────────────┴───────────┐ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Findings Ledger │ │ Policy Engine │ │
|
||||
│ │ (staleness tracking) │ │ (evaluation gating) │ │
|
||||
│ └──────────────────────┘ └──────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 4. Core Types
|
||||
|
||||
### 4.1 TimeAnchor
|
||||
|
||||
A cryptographically signed time reference:
|
||||
|
||||
```csharp
|
||||
public sealed record TimeAnchor
|
||||
{
|
||||
/// <summary>RFC 3339 timestamp of the anchor.</summary>
|
||||
public required DateTimeOffset AnchorTime { get; init; }
|
||||
|
||||
/// <summary>Source of the time anchor.</summary>
|
||||
public required TimeSource Source { get; init; }
|
||||
|
||||
/// <summary>Format identifier (roughtime-v1, rfc3161-v1).</summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>SHA-256 digest of the time token.</summary>
|
||||
public required string TokenDigest { get; init; }
|
||||
|
||||
/// <summary>Signing key fingerprint.</summary>
|
||||
public string? SignatureFingerprint { get; init; }
|
||||
|
||||
/// <summary>Verification status.</summary>
|
||||
public VerificationStatus? Verification { get; init; }
|
||||
|
||||
/// <summary>Monotonic counter for replay protection.</summary>
|
||||
public long? MonotonicCounter { get; init; }
|
||||
}
|
||||
|
||||
public enum TimeSource
|
||||
{
|
||||
Roughtime = 0,
|
||||
Rfc3161 = 1,
|
||||
HardwareClock = 2,
|
||||
AttestationTsa = 3,
|
||||
Manual = 4,
|
||||
Unknown = 5
|
||||
}
|
||||
|
||||
public sealed record VerificationStatus
|
||||
{
|
||||
public required VerificationState Status { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
public enum VerificationState
|
||||
{
|
||||
Unknown = 0,
|
||||
Passed = 1,
|
||||
Failed = 2
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 StalenessBudget
|
||||
|
||||
Configuration for acceptable data freshness:
|
||||
|
||||
```csharp
|
||||
public sealed record StalenessBudget
|
||||
{
|
||||
/// <summary>Budget identifier.</summary>
|
||||
public required string BudgetId { get; init; }
|
||||
|
||||
/// <summary>Domain this budget applies to.</summary>
|
||||
public required string DomainId { get; init; }
|
||||
|
||||
/// <summary>Maximum staleness in seconds before data is stale.</summary>
|
||||
public required TimeSpan FreshnessThreshold { get; init; }
|
||||
|
||||
/// <summary>Warning threshold (percentage of freshness threshold).</summary>
|
||||
public decimal WarningThresholdPercent { get; init; } = 75m;
|
||||
|
||||
/// <summary>Critical threshold (percentage of freshness threshold).</summary>
|
||||
public decimal CriticalThresholdPercent { get; init; } = 90m;
|
||||
|
||||
/// <summary>Grace period after threshold before hard enforcement.</summary>
|
||||
public TimeSpan GracePeriod { get; init; } = TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>Enforcement mode.</summary>
|
||||
public EnforcementMode EnforcementMode { get; init; } = EnforcementMode.Strict;
|
||||
}
|
||||
|
||||
public enum EnforcementMode
|
||||
{
|
||||
Strict = 0, // Block operations when stale
|
||||
Warn = 1, // Allow but log warnings
|
||||
Disabled = 2 // No enforcement
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 StalenessEvaluation
|
||||
|
||||
Result of staleness calculation:
|
||||
|
||||
```csharp
|
||||
public sealed record StalenessEvaluation
|
||||
{
|
||||
/// <summary>Domain evaluated.</summary>
|
||||
public required string DomainId { get; init; }
|
||||
|
||||
/// <summary>Current staleness duration.</summary>
|
||||
public required TimeSpan CurrentStaleness { get; init; }
|
||||
|
||||
/// <summary>Configured threshold.</summary>
|
||||
public required TimeSpan Threshold { get; init; }
|
||||
|
||||
/// <summary>Staleness as percentage of threshold.</summary>
|
||||
public required decimal PercentOfThreshold { get; init; }
|
||||
|
||||
/// <summary>Overall status.</summary>
|
||||
public required StalenessStatus Status { get; init; }
|
||||
|
||||
/// <summary>When data will become stale.</summary>
|
||||
public DateTimeOffset? ProjectedStaleAt { get; init; }
|
||||
|
||||
/// <summary>Time anchor used for calculation.</summary>
|
||||
public required TimeAnchor TimeAnchor { get; init; }
|
||||
|
||||
/// <summary>Last bundle import timestamp.</summary>
|
||||
public required DateTimeOffset LastImportAt { get; init; }
|
||||
|
||||
/// <summary>Source timestamp of last bundle.</summary>
|
||||
public required DateTimeOffset LastSourceTimestamp { get; init; }
|
||||
}
|
||||
|
||||
public enum StalenessStatus
|
||||
{
|
||||
Fresh = 0, // < warning threshold
|
||||
Warning = 1, // >= warning, < critical
|
||||
Critical = 2, // >= critical, < threshold
|
||||
Stale = 3, // >= threshold, < threshold + grace
|
||||
Breached = 4 // >= threshold + grace
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 BundleProvenance
|
||||
|
||||
Provenance record for imported bundles:
|
||||
|
||||
```csharp
|
||||
public sealed record BundleProvenance
|
||||
{
|
||||
/// <summary>Unique bundle identifier.</summary>
|
||||
public required Guid BundleId { get; init; }
|
||||
|
||||
/// <summary>Bundle domain (vex-advisories, vulnerability-feeds, etc.).</summary>
|
||||
public required string DomainId { get; init; }
|
||||
|
||||
/// <summary>When bundle was imported.</summary>
|
||||
public required DateTimeOffset ImportedAt { get; init; }
|
||||
|
||||
/// <summary>Original generation timestamp from source.</summary>
|
||||
public required DateTimeOffset SourceTimestamp { get; init; }
|
||||
|
||||
/// <summary>Source environment identifier.</summary>
|
||||
public string? SourceEnvironment { get; init; }
|
||||
|
||||
/// <summary>SHA-256 digest of bundle contents.</summary>
|
||||
public required string BundleDigest { get; init; }
|
||||
|
||||
/// <summary>SHA-256 digest of bundle manifest.</summary>
|
||||
public string? ManifestDigest { get; init; }
|
||||
|
||||
/// <summary>Staleness at import time.</summary>
|
||||
public required TimeSpan StalenessAtImport { get; init; }
|
||||
|
||||
/// <summary>Time anchor used for staleness calculation.</summary>
|
||||
public required TimeAnchor TimeAnchor { get; init; }
|
||||
|
||||
/// <summary>DSSE attestation covering this bundle.</summary>
|
||||
public BundleAttestation? Attestation { get; init; }
|
||||
|
||||
/// <summary>Exports included in this bundle.</summary>
|
||||
public ImmutableArray<ExportRecord> Exports { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Staleness Domains
|
||||
|
||||
| Domain ID | Description | Default Threshold | Default Grace |
|
||||
|-----------|-------------|-------------------|---------------|
|
||||
| `vulnerability-feeds` | Advisory and CVE data | 7 days | 1 day |
|
||||
| `vex-advisories` | VEX statements | 7 days | 1 day |
|
||||
| `scanner-signatures` | Scanner detection rules | 14 days | 3 days |
|
||||
| `policy-packs` | Policy bundles | 30 days | 7 days |
|
||||
| `trust-roots` | Certificate/key roots | 90 days | 14 days |
|
||||
| `runtime-evidence` | Runtime observation data | 1 day | 4 hours |
|
||||
|
||||
## 6. Time Anchor Verification
|
||||
|
||||
### 6.1 Roughtime Verification
|
||||
|
||||
```csharp
|
||||
public interface IRoughtimeVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a Roughtime response against trusted servers.
|
||||
/// </summary>
|
||||
Task<TimeAnchorValidationResult> VerifyAsync(
|
||||
byte[] roughtimeResponse,
|
||||
RoughtimeRoot[] trustedRoots,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Roughtime provides:
|
||||
- Sub-second accuracy with 1-2 second uncertainty
|
||||
- Ed25519 signatures
|
||||
- Chain of trust via server public keys
|
||||
- Radius-based uncertainty bounds
|
||||
|
||||
### 6.2 RFC 3161 Verification
|
||||
|
||||
```csharp
|
||||
public interface IRfc3161Verifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies an RFC 3161 timestamp token.
|
||||
/// </summary>
|
||||
Task<TimeAnchorValidationResult> VerifyAsync(
|
||||
byte[] timestampToken,
|
||||
Rfc3161Root[] trustedRoots,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
RFC 3161 provides:
|
||||
- X.509 certificate-based trust
|
||||
- ASN.1/DER encoded tokens
|
||||
- Hash algorithm binding
|
||||
- Nonce for uniqueness
|
||||
|
||||
### 6.3 Validation Result
|
||||
|
||||
```csharp
|
||||
public sealed record TimeAnchorValidationResult
|
||||
{
|
||||
public required bool IsValid { get; init; }
|
||||
public required TimeAnchor? Anchor { get; init; }
|
||||
public TimeAnchorError? Error { get; init; }
|
||||
public TimeSpan? Uncertainty { get; init; }
|
||||
}
|
||||
|
||||
public enum TimeAnchorError
|
||||
{
|
||||
None = 0,
|
||||
SignatureInvalid = 1,
|
||||
RootNotTrusted = 2,
|
||||
TokenExpired = 3,
|
||||
TokenMalformed = 4,
|
||||
CounterReplay = 5,
|
||||
UncertaintyTooHigh = 6
|
||||
}
|
||||
```
|
||||
|
||||
## 7. API Endpoints
|
||||
|
||||
### 7.1 AirGap Time Service
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `GET /api/v1/time/status` | GET | Current anchor metadata and drift |
|
||||
| `GET /api/v1/time/anchor` | GET | Active time anchor |
|
||||
| `POST /api/v1/time/anchor` | POST | Import new time anchor |
|
||||
| `GET /api/v1/time/metrics` | GET | Prometheus metrics |
|
||||
| `GET /api/v1/time/health` | GET | Health check |
|
||||
|
||||
### 7.2 Staleness Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `GET /api/v1/staleness/domains` | GET | List all domain staleness |
|
||||
| `GET /api/v1/staleness/domains/{domainId}` | GET | Get domain staleness |
|
||||
| `POST /api/v1/staleness/validate` | POST | Validate staleness for context |
|
||||
| `GET /api/v1/staleness/config` | GET | Get staleness configuration |
|
||||
| `PUT /api/v1/staleness/config` | PUT | Update staleness configuration |
|
||||
|
||||
### 7.3 Response Formats
|
||||
|
||||
```json
|
||||
{
|
||||
"domainId": "vex-advisories",
|
||||
"currentStaleness": "PT172800S",
|
||||
"threshold": "PT604800S",
|
||||
"percentOfThreshold": 28.57,
|
||||
"status": "fresh",
|
||||
"projectedStaleAt": "2025-12-26T10:00:00Z",
|
||||
"timeAnchor": {
|
||||
"anchorTime": "2025-12-19T10:00:00Z",
|
||||
"source": "roughtime",
|
||||
"format": "roughtime-v1",
|
||||
"tokenDigest": "sha256:abc123...",
|
||||
"verification": {
|
||||
"status": "passed",
|
||||
"verifiedAt": "2025-12-19T10:00:01Z"
|
||||
}
|
||||
},
|
||||
"lastImportAt": "2025-12-17T10:00:00Z",
|
||||
"lastSourceTimestamp": "2025-12-17T08:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Integration Points
|
||||
|
||||
### 8.1 Findings Ledger Integration
|
||||
|
||||
The Ledger tracks staleness per projection:
|
||||
|
||||
```csharp
|
||||
public interface IStalenessValidationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that data is fresh enough for the given context.
|
||||
/// </summary>
|
||||
Task<StalenessValidationResult> ValidateAsync(
|
||||
string tenantId,
|
||||
string domainId,
|
||||
StalenessContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates staleness tracking after bundle import.
|
||||
/// </summary>
|
||||
Task UpdateStalenessAsync(
|
||||
string tenantId,
|
||||
BundleProvenance provenance,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public enum StalenessContext
|
||||
{
|
||||
Export = 0, // Generating exports
|
||||
Query = 1, // Querying data
|
||||
PolicyEval = 2, // Policy evaluation
|
||||
Attestation = 3 // Creating attestations
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Policy Engine Integration
|
||||
|
||||
Policy Engine gates evaluations based on staleness:
|
||||
|
||||
```csharp
|
||||
public interface ISealedModeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if sealed mode should block the operation.
|
||||
/// </summary>
|
||||
Task<SealedModeDecision> CheckAsync(
|
||||
string tenantId,
|
||||
SealedModeContext context,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record SealedModeDecision
|
||||
{
|
||||
public required bool IsBlocked { get; init; }
|
||||
public SealedModeReason? Reason { get; init; }
|
||||
public ImmutableArray<StalenessEvaluation> StaleDomains { get; init; }
|
||||
}
|
||||
|
||||
public enum SealedModeReason
|
||||
{
|
||||
None = 0,
|
||||
DataStale = 1,
|
||||
TimeAnchorMissing = 2,
|
||||
TimeAnchorExpired = 3,
|
||||
SignatureInvalid = 4
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Telemetry
|
||||
|
||||
### 9.1 Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `airgap_anchor_age_seconds` | gauge | - | Age of current time anchor |
|
||||
| `airgap_anchor_drift_seconds` | gauge | - | Drift from anchor time |
|
||||
| `airgap_anchor_expiry_seconds` | gauge | - | Seconds until anchor expires |
|
||||
| `airgap_staleness_seconds` | gauge | `domain` | Current staleness per domain |
|
||||
| `airgap_staleness_threshold_seconds` | gauge | `domain` | Threshold per domain |
|
||||
| `airgap_staleness_percent` | gauge | `domain` | Staleness as % of threshold |
|
||||
| `airgap_staleness_status` | gauge | `domain`, `status` | Current status (0=fresh, 3=stale) |
|
||||
| `airgap_bundle_imports_total` | counter | `domain`, `result` | Bundle imports |
|
||||
| `airgap_validation_total` | counter | `domain`, `context`, `result` | Staleness validations |
|
||||
|
||||
### 9.2 Alerts
|
||||
|
||||
```yaml
|
||||
# Recommended alerting rules
|
||||
groups:
|
||||
- name: airgap-staleness
|
||||
rules:
|
||||
- alert: AirGapDataApproachingStale
|
||||
expr: airgap_staleness_percent > 75
|
||||
for: 1h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "{{ $labels.domain }} data approaching staleness"
|
||||
|
||||
- alert: AirGapDataStale
|
||||
expr: airgap_staleness_percent >= 100
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "{{ $labels.domain }} data is stale"
|
||||
|
||||
- alert: AirGapTimeAnchorMissing
|
||||
expr: airgap_anchor_age_seconds > 86400
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Time anchor is older than 24 hours"
|
||||
```
|
||||
|
||||
## 10. Configuration
|
||||
|
||||
```yaml
|
||||
# etc/airgap.yaml
|
||||
AirGap:
|
||||
Time:
|
||||
Enabled: true
|
||||
TrustRootsPath: "/etc/stellaops/trust-roots.json"
|
||||
MaxAnchorAgeHours: 168 # 7 days
|
||||
MaxUncertaintyMs: 5000 # 5 seconds
|
||||
|
||||
Staleness:
|
||||
DefaultThresholdDays: 7
|
||||
DefaultGracePeriodDays: 1
|
||||
EnforcementMode: "Strict" # Strict, Warn, Disabled
|
||||
|
||||
Domains:
|
||||
vulnerability-feeds:
|
||||
ThresholdDays: 7
|
||||
GracePeriodDays: 1
|
||||
vex-advisories:
|
||||
ThresholdDays: 7
|
||||
GracePeriodDays: 1
|
||||
runtime-evidence:
|
||||
ThresholdDays: 1
|
||||
GracePeriodHours: 4
|
||||
|
||||
Notifications:
|
||||
- PercentOfThreshold: 75
|
||||
Severity: warning
|
||||
Channels: [slack, metric]
|
||||
- PercentOfThreshold: 90
|
||||
Severity: critical
|
||||
Channels: [email, slack, metric]
|
||||
```
|
||||
|
||||
## 11. Error Codes
|
||||
|
||||
| Code | Description | Resolution |
|
||||
|------|-------------|------------|
|
||||
| `ERR_AIRGAP_STALE` | Data exceeds staleness threshold | Import fresh bundle |
|
||||
| `ERR_AIRGAP_NO_BUNDLE` | No bundle imported for domain | Import initial bundle |
|
||||
| `ERR_AIRGAP_TIME_ANCHOR_MISSING` | No time anchor available | Import time anchor with bundle |
|
||||
| `ERR_AIRGAP_TIME_DRIFT` | Excessive drift detected | Re-verify time anchor |
|
||||
| `ERR_AIRGAP_ATTESTATION_INVALID` | Bundle attestation invalid | Verify bundle source |
|
||||
| `ERR_AIRGAP_SIGNATURE_INVALID` | Time token signature invalid | Check trust roots |
|
||||
| `ERR_AIRGAP_COUNTER_REPLAY` | Monotonic counter replay | Import newer anchor |
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.0.0 | 2025-12-19 | Initial release |
|
||||
Reference in New Issue
Block a user