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

- 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:
master
2025-12-16 16:40:19 +02:00
parent 415eff1207
commit 2170a58734
206 changed files with 30547 additions and 534 deletions

View File

@@ -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;
}
}

View File

@@ -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; }
}
}