Add comprehensive security tests for OWASP A02, A05, A07, and A08 categories
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
- Implemented tests for Cryptographic Failures (A02) to ensure proper handling of sensitive data, secure algorithms, and key management. - Added tests for Security Misconfiguration (A05) to validate production configurations, security headers, CORS settings, and feature management. - Developed tests for Authentication Failures (A07) to enforce strong password policies, rate limiting, session management, and MFA support. - Created tests for Software and Data Integrity Failures (A08) to verify artifact signatures, SBOM integrity, attestation chains, and feed updates.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks classification changes for FN-Drift analysis.
|
||||
/// SPRINT_3404_0001_0001 - Task #6
|
||||
/// </summary>
|
||||
public interface IClassificationChangeTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a classification change for drift tracking.
|
||||
/// </summary>
|
||||
Task TrackChangeAsync(ClassificationChange change, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records multiple classification changes in batch.
|
||||
/// </summary>
|
||||
Task TrackChangesAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the classification delta between two scan executions.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ClassificationChange>> ComputeDeltaAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
Guid previousExecutionId,
|
||||
Guid currentExecutionId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of classification change tracking.
|
||||
/// </summary>
|
||||
public sealed class ClassificationChangeTracker : IClassificationChangeTracker
|
||||
{
|
||||
private readonly IClassificationHistoryRepository _repository;
|
||||
private readonly ILogger<ClassificationChangeTracker> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ClassificationChangeTracker(
|
||||
IClassificationHistoryRepository repository,
|
||||
ILogger<ClassificationChangeTracker> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task TrackChangeAsync(ClassificationChange change, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
|
||||
// Only track actual changes
|
||||
if (change.PreviousStatus == change.NewStatus)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping no-op classification change for {VulnId} on {Artifact}",
|
||||
change.VulnId,
|
||||
TruncateDigest(change.ArtifactDigest));
|
||||
return;
|
||||
}
|
||||
|
||||
await _repository.InsertAsync(change, cancellationToken);
|
||||
|
||||
if (change.IsFnTransition)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"FN-Drift detected: {VulnId} on {Artifact} changed from {Previous} to {New} (cause: {Cause})",
|
||||
change.VulnId,
|
||||
TruncateDigest(change.ArtifactDigest),
|
||||
change.PreviousStatus,
|
||||
change.NewStatus,
|
||||
change.Cause);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Classification change: {VulnId} on {Artifact}: {Previous} -> {New}",
|
||||
change.VulnId,
|
||||
TruncateDigest(change.ArtifactDigest),
|
||||
change.PreviousStatus,
|
||||
change.NewStatus);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task TrackChangesAsync(
|
||||
IEnumerable<ClassificationChange> changes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changes);
|
||||
|
||||
var changeList = changes
|
||||
.Where(c => c.PreviousStatus != c.NewStatus)
|
||||
.ToList();
|
||||
|
||||
if (changeList.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _repository.InsertBatchAsync(changeList, cancellationToken);
|
||||
|
||||
var fnCount = changeList.Count(c => c.IsFnTransition);
|
||||
if (fnCount > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"FN-Drift batch: {FnCount} false-negative transitions out of {Total} changes",
|
||||
fnCount,
|
||||
changeList.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ClassificationChange>> ComputeDeltaAsync(
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
Guid previousExecutionId,
|
||||
Guid currentExecutionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(artifactDigest);
|
||||
|
||||
// Get classifications from both executions
|
||||
var previousClassifications = await _repository.GetByExecutionAsync(
|
||||
tenantId, previousExecutionId, cancellationToken);
|
||||
var currentClassifications = await _repository.GetByExecutionAsync(
|
||||
tenantId, currentExecutionId, cancellationToken);
|
||||
|
||||
// Index by vuln+package
|
||||
var previousByKey = previousClassifications
|
||||
.Where(c => c.ArtifactDigest == artifactDigest)
|
||||
.ToDictionary(c => (c.VulnId, c.PackagePurl));
|
||||
|
||||
var currentByKey = currentClassifications
|
||||
.Where(c => c.ArtifactDigest == artifactDigest)
|
||||
.ToDictionary(c => (c.VulnId, c.PackagePurl));
|
||||
|
||||
var changes = new List<ClassificationChange>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Find status changes
|
||||
foreach (var (key, current) in currentByKey)
|
||||
{
|
||||
if (previousByKey.TryGetValue(key, out var previous))
|
||||
{
|
||||
if (previous.NewStatus != current.NewStatus)
|
||||
{
|
||||
changes.Add(new ClassificationChange
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
VulnId = key.VulnId,
|
||||
PackagePurl = key.PackagePurl,
|
||||
TenantId = tenantId,
|
||||
ManifestId = current.ManifestId,
|
||||
ExecutionId = currentExecutionId,
|
||||
PreviousStatus = previous.NewStatus,
|
||||
NewStatus = current.NewStatus,
|
||||
Cause = DetermineCause(previous, current),
|
||||
ChangedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New finding
|
||||
changes.Add(new ClassificationChange
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
VulnId = key.VulnId,
|
||||
PackagePurl = key.PackagePurl,
|
||||
TenantId = tenantId,
|
||||
ManifestId = current.ManifestId,
|
||||
ExecutionId = currentExecutionId,
|
||||
PreviousStatus = ClassificationStatus.New,
|
||||
NewStatus = current.NewStatus,
|
||||
Cause = DriftCause.FeedDelta,
|
||||
ChangedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Heuristically determine the cause of drift based on change metadata.
|
||||
/// </summary>
|
||||
private static DriftCause DetermineCause(ClassificationChange previous, ClassificationChange current)
|
||||
{
|
||||
// Check cause detail for hints
|
||||
var prevDetail = previous.CauseDetail ?? new Dictionary<string, string>();
|
||||
var currDetail = current.CauseDetail ?? new Dictionary<string, string>();
|
||||
|
||||
// Feed version change
|
||||
if (prevDetail.TryGetValue("feedVersion", out var prevFeed) &&
|
||||
currDetail.TryGetValue("feedVersion", out var currFeed) &&
|
||||
prevFeed != currFeed)
|
||||
{
|
||||
return DriftCause.FeedDelta;
|
||||
}
|
||||
|
||||
// Policy rule change
|
||||
if (prevDetail.TryGetValue("ruleHash", out var prevRule) &&
|
||||
currDetail.TryGetValue("ruleHash", out var currRule) &&
|
||||
prevRule != currRule)
|
||||
{
|
||||
return DriftCause.RuleDelta;
|
||||
}
|
||||
|
||||
// VEX lattice change
|
||||
if (prevDetail.TryGetValue("vexHash", out var prevVex) &&
|
||||
currDetail.TryGetValue("vexHash", out var currVex) &&
|
||||
prevVex != currVex)
|
||||
{
|
||||
return DriftCause.LatticeDelta;
|
||||
}
|
||||
|
||||
// Reachability change
|
||||
if (prevDetail.TryGetValue("reachable", out var prevReach) &&
|
||||
currDetail.TryGetValue("reachable", out var currReach) &&
|
||||
prevReach != currReach)
|
||||
{
|
||||
return DriftCause.ReachabilityDelta;
|
||||
}
|
||||
|
||||
// Default to feed delta (most common)
|
||||
return DriftCause.FeedDelta;
|
||||
}
|
||||
|
||||
private static string TruncateDigest(string digest)
|
||||
{
|
||||
const int maxLen = 16;
|
||||
return digest.Length > maxLen ? digest[..maxLen] + "..." : digest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Prometheus metrics exporter for FN-Drift tracking.
|
||||
/// SPRINT_3404_0001_0001 - Task #9
|
||||
/// </summary>
|
||||
public sealed class FnDriftMetricsExporter : BackgroundService
|
||||
{
|
||||
public const string MeterName = "StellaOps.Scanner.FnDrift";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly IClassificationHistoryRepository _repository;
|
||||
private readonly ILogger<FnDriftMetricsExporter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _refreshInterval;
|
||||
|
||||
// Observable gauges (updated periodically)
|
||||
private readonly ObservableGauge<double> _fnDriftPercentGauge;
|
||||
private readonly ObservableGauge<long> _fnTransitionsGauge;
|
||||
private readonly ObservableGauge<long> _totalEvaluatedGauge;
|
||||
private readonly ObservableGauge<long> _feedDeltaCountGauge;
|
||||
private readonly ObservableGauge<long> _ruleDeltaCountGauge;
|
||||
private readonly ObservableGauge<long> _latticeDeltaCountGauge;
|
||||
private readonly ObservableGauge<long> _reachabilityDeltaCountGauge;
|
||||
private readonly ObservableGauge<long> _engineDeltaCountGauge;
|
||||
|
||||
// Counters (incremented on each change)
|
||||
private readonly Counter<long> _classificationChangesCounter;
|
||||
private readonly Counter<long> _fnTransitionsCounter;
|
||||
|
||||
// Current state for observable gauges
|
||||
private volatile FnDriftSnapshot _currentSnapshot = new();
|
||||
|
||||
public FnDriftMetricsExporter(
|
||||
IClassificationHistoryRepository repository,
|
||||
ILogger<FnDriftMetricsExporter> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
TimeSpan? refreshInterval = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_refreshInterval = refreshInterval ?? TimeSpan.FromMinutes(1);
|
||||
|
||||
_meter = new Meter(MeterName);
|
||||
|
||||
// Observable gauges - read from snapshot
|
||||
_fnDriftPercentGauge = _meter.CreateObservableGauge(
|
||||
"scanner.fn_drift.percent",
|
||||
() => _currentSnapshot.FnDriftPercent,
|
||||
unit: "%",
|
||||
description: "30-day rolling FN-Drift percentage");
|
||||
|
||||
_fnTransitionsGauge = _meter.CreateObservableGauge(
|
||||
"scanner.fn_drift.transitions_30d",
|
||||
() => _currentSnapshot.FnTransitions,
|
||||
description: "FN transitions in last 30 days");
|
||||
|
||||
_totalEvaluatedGauge = _meter.CreateObservableGauge(
|
||||
"scanner.fn_drift.evaluated_30d",
|
||||
() => _currentSnapshot.TotalEvaluated,
|
||||
description: "Total findings evaluated in last 30 days");
|
||||
|
||||
_feedDeltaCountGauge = _meter.CreateObservableGauge(
|
||||
"scanner.fn_drift.cause.feed_delta",
|
||||
() => _currentSnapshot.FeedDeltaCount,
|
||||
description: "FN transitions caused by feed updates");
|
||||
|
||||
_ruleDeltaCountGauge = _meter.CreateObservableGauge(
|
||||
"scanner.fn_drift.cause.rule_delta",
|
||||
() => _currentSnapshot.RuleDeltaCount,
|
||||
description: "FN transitions caused by rule changes");
|
||||
|
||||
_latticeDeltaCountGauge = _meter.CreateObservableGauge(
|
||||
"scanner.fn_drift.cause.lattice_delta",
|
||||
() => _currentSnapshot.LatticeDeltaCount,
|
||||
description: "FN transitions caused by VEX lattice changes");
|
||||
|
||||
_reachabilityDeltaCountGauge = _meter.CreateObservableGauge(
|
||||
"scanner.fn_drift.cause.reachability_delta",
|
||||
() => _currentSnapshot.ReachabilityDeltaCount,
|
||||
description: "FN transitions caused by reachability changes");
|
||||
|
||||
_engineDeltaCountGauge = _meter.CreateObservableGauge(
|
||||
"scanner.fn_drift.cause.engine",
|
||||
() => _currentSnapshot.EngineDeltaCount,
|
||||
description: "FN transitions caused by engine changes (should be ~0)");
|
||||
|
||||
// Counters - incremented per event
|
||||
_classificationChangesCounter = _meter.CreateCounter<long>(
|
||||
"scanner.classification_changes_total",
|
||||
description: "Total classification status changes");
|
||||
|
||||
_fnTransitionsCounter = _meter.CreateCounter<long>(
|
||||
"scanner.fn_transitions_total",
|
||||
description: "Total false-negative transitions");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a classification change for metrics.
|
||||
/// </summary>
|
||||
public void RecordClassificationChange(bool isFnTransition, string cause)
|
||||
{
|
||||
_classificationChangesCounter.Add(1, new KeyValuePair<string, object?>("cause", cause));
|
||||
|
||||
if (isFnTransition)
|
||||
{
|
||||
_fnTransitionsCounter.Add(1, new KeyValuePair<string, object?>("cause", cause));
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("FN-Drift metrics exporter starting with {Interval} refresh interval",
|
||||
_refreshInterval);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RefreshMetricsAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh FN-Drift metrics, will retry");
|
||||
}
|
||||
|
||||
await Task.Delay(_refreshInterval, _timeProvider, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("FN-Drift metrics exporter stopped");
|
||||
}
|
||||
|
||||
private async Task RefreshMetricsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Get 30-day summary for all tenants (aggregated)
|
||||
// In production, this would iterate over active tenants
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var fromDate = DateOnly.FromDateTime(now.AddDays(-30).DateTime);
|
||||
var toDate = DateOnly.FromDateTime(now.DateTime);
|
||||
|
||||
var stats = await _repository.GetDriftStatsAsync(
|
||||
Guid.Empty, // Aggregate across tenants
|
||||
fromDate,
|
||||
toDate,
|
||||
cancellationToken);
|
||||
|
||||
// Aggregate stats into snapshot
|
||||
var snapshot = new FnDriftSnapshot();
|
||||
|
||||
foreach (var stat in stats)
|
||||
{
|
||||
snapshot.FnTransitions += stat.FnCount;
|
||||
snapshot.TotalEvaluated += stat.TotalReclassified;
|
||||
snapshot.FeedDeltaCount += stat.FeedDeltaCount;
|
||||
snapshot.RuleDeltaCount += stat.RuleDeltaCount;
|
||||
snapshot.LatticeDeltaCount += stat.LatticeDeltaCount;
|
||||
snapshot.ReachabilityDeltaCount += stat.ReachabilityDeltaCount;
|
||||
snapshot.EngineDeltaCount += stat.EngineCount;
|
||||
}
|
||||
|
||||
if (snapshot.TotalEvaluated > 0)
|
||||
{
|
||||
snapshot.FnDriftPercent = (double)snapshot.FnTransitions / snapshot.TotalEvaluated * 100;
|
||||
}
|
||||
|
||||
_currentSnapshot = snapshot;
|
||||
|
||||
_logger.LogDebug(
|
||||
"FN-Drift metrics refreshed: {FnPercent:F2}% ({FnCount}/{Total})",
|
||||
snapshot.FnDriftPercent,
|
||||
snapshot.FnTransitions,
|
||||
snapshot.TotalEvaluated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of FN-Drift metrics for observable gauges.
|
||||
/// </summary>
|
||||
private sealed class FnDriftSnapshot
|
||||
{
|
||||
public double FnDriftPercent { get; set; }
|
||||
public long FnTransitions { get; set; }
|
||||
public long TotalEvaluated { get; set; }
|
||||
public long FeedDeltaCount { get; set; }
|
||||
public long RuleDeltaCount { get; set; }
|
||||
public long LatticeDeltaCount { get; set; }
|
||||
public long ReachabilityDeltaCount { get; set; }
|
||||
public long EngineDeltaCount { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user