feat(metrics): Implement scan metrics repository and PostgreSQL integration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added IScanMetricsRepository interface for scan metrics persistence and retrieval.
- Implemented PostgresScanMetricsRepository for PostgreSQL database interactions, including methods for saving and retrieving scan metrics and execution phases.
- Introduced methods for obtaining TTE statistics and recent scans for tenants.
- Implemented deletion of old metrics for retention purposes.

test(tests): Add SCA Failure Catalogue tests for FC6-FC10

- Created ScaCatalogueDeterminismTests to validate determinism properties of SCA Failure Catalogue fixtures.
- Developed ScaFailureCatalogueTests to ensure correct handling of specific failure modes in the scanner.
- Included tests for manifest validation, file existence, and expected findings across multiple failure cases.

feat(telemetry): Integrate scan completion metrics into the pipeline

- Introduced IScanCompletionMetricsIntegration interface and ScanCompletionMetricsIntegration class to record metrics upon scan completion.
- Implemented proof coverage and TTE metrics recording with logging for scan completion summaries.
This commit is contained in:
master
2025-12-16 14:00:35 +02:00
parent b55d9fa68d
commit 415eff1207
27 changed files with 3620 additions and 35 deletions

View File

@@ -0,0 +1,129 @@
// -----------------------------------------------------------------------------
// FreshnessAwareScoringService.cs
// Sprint: SPRINT_3401_0001_0001_determinism_scoring_foundations
// Task: DET-3401-003
// Description: Integrates freshness multiplier into evidence scoring pipeline
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Scoring;
/// <summary>
/// Integrates evidence freshness into the scoring pipeline.
/// </summary>
public interface IFreshnessAwareScoringService
{
/// <summary>
/// Applies freshness adjustment to a finding's score.
/// </summary>
/// <param name="baseScore">Original score (0-100 or 0-10 depending on scale).</param>
/// <param name="evidenceTimestamp">When the evidence was collected.</param>
/// <param name="evaluationTime">Time of evaluation (for deterministic replay).</param>
/// <returns>Adjusted score with explanation.</returns>
FreshnessAdjustedScore AdjustForFreshness(
int baseScore,
DateTimeOffset evidenceTimestamp,
DateTimeOffset evaluationTime);
/// <summary>
/// Gets the freshness bucket for a given evidence age.
/// </summary>
FreshnessBucketResult GetFreshnessBucket(
DateTimeOffset evidenceTimestamp,
DateTimeOffset evaluationTime);
}
/// <summary>
/// Result of freshness adjustment with explanation data.
/// </summary>
/// <param name="OriginalScore">The score before freshness adjustment.</param>
/// <param name="AdjustedScore">The score after freshness adjustment.</param>
/// <param name="MultiplierBps">The multiplier applied (basis points).</param>
/// <param name="EvidenceAgeDays">Age of the evidence in days.</param>
/// <param name="BucketName">Name of the freshness bucket.</param>
public sealed record FreshnessAdjustedScore(
int OriginalScore,
int AdjustedScore,
int MultiplierBps,
int EvidenceAgeDays,
string BucketName);
/// <summary>
/// Result of freshness bucket lookup.
/// </summary>
/// <param name="AgeDays">Age of evidence in days.</param>
/// <param name="BucketName">Human-readable bucket name.</param>
/// <param name="MultiplierBps">Multiplier in basis points.</param>
/// <param name="MultiplierPercent">Multiplier as percentage.</param>
public sealed record FreshnessBucketResult(
int AgeDays,
string BucketName,
int MultiplierBps,
decimal MultiplierPercent);
public sealed class FreshnessAwareScoringService : IFreshnessAwareScoringService
{
private readonly EvidenceFreshnessCalculator _calculator;
private readonly ILogger<FreshnessAwareScoringService> _logger;
public FreshnessAwareScoringService(
FreshnessMultiplierConfig? config = null,
ILogger<FreshnessAwareScoringService>? logger = null)
{
_calculator = new EvidenceFreshnessCalculator(config);
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FreshnessAwareScoringService>.Instance;
}
public FreshnessAdjustedScore AdjustForFreshness(
int baseScore,
DateTimeOffset evidenceTimestamp,
DateTimeOffset evaluationTime)
{
var ageDays = (int)(evaluationTime - evidenceTimestamp).TotalDays;
if (ageDays < 0) ageDays = 0;
var multiplierBps = _calculator.CalculateMultiplierBps(evidenceTimestamp, evaluationTime);
var adjustedScore = _calculator.ApplyFreshness(baseScore, evidenceTimestamp, evaluationTime);
var bucketName = GetBucketName(ageDays);
_logger.LogDebug(
"Freshness adjustment: base={BaseScore}, adjusted={AdjustedScore}, age={AgeDays}d, bucket={Bucket}, multiplier={Mult}bps",
baseScore, adjustedScore, ageDays, bucketName, multiplierBps);
return new FreshnessAdjustedScore(
OriginalScore: baseScore,
AdjustedScore: adjustedScore,
MultiplierBps: multiplierBps,
EvidenceAgeDays: ageDays,
BucketName: bucketName);
}
public FreshnessBucketResult GetFreshnessBucket(
DateTimeOffset evidenceTimestamp,
DateTimeOffset evaluationTime)
{
var ageDays = (int)(evaluationTime - evidenceTimestamp).TotalDays;
if (ageDays < 0) ageDays = 0;
var multiplierBps = _calculator.CalculateMultiplierBps(evidenceTimestamp, evaluationTime);
var bucketName = GetBucketName(ageDays);
var multiplierPercent = multiplierBps / 100m;
return new FreshnessBucketResult(
AgeDays: ageDays,
BucketName: bucketName,
MultiplierBps: multiplierBps,
MultiplierPercent: multiplierPercent);
}
private static string GetBucketName(int ageDays) => ageDays switch
{
<= 7 => "fresh_7d",
<= 30 => "recent_30d",
<= 90 => "moderate_90d",
<= 180 => "aging_180d",
<= 365 => "stale_365d",
_ => "ancient"
};
}

View File

@@ -0,0 +1,172 @@
// -----------------------------------------------------------------------------
// DeterminismScoringIntegrationTests.cs
// Sprint: SPRINT_3401_0001_0001_determinism_scoring_foundations
// Task: DET-3401-013
// Description: Integration tests for freshness + proof coverage + explain in full scan
// -----------------------------------------------------------------------------
using StellaOps.Policy.Scoring;
namespace StellaOps.Policy.Scoring.Tests;
public class DeterminismScoringIntegrationTests
{
private readonly IFreshnessAwareScoringService _freshnessService;
public DeterminismScoringIntegrationTests()
{
_freshnessService = new FreshnessAwareScoringService();
}
#region Freshness Integration Tests
[Fact]
public void FreshnessAdjustment_WithExplanation_ProducesConsistentResults()
{
// Arrange
var evaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
var evidenceTime = evaluationTime.AddDays(-15); // 15 days old = recent_30d bucket
var baseScore = 100;
// Act
var result1 = _freshnessService.AdjustForFreshness(baseScore, evidenceTime, evaluationTime);
var result2 = _freshnessService.AdjustForFreshness(baseScore, evidenceTime, evaluationTime);
// Assert
Assert.Equal(result1.AdjustedScore, result2.AdjustedScore);
Assert.Equal(result1.MultiplierBps, result2.MultiplierBps);
Assert.Equal("recent_30d", result1.BucketName);
Assert.Equal(9000, result1.MultiplierBps); // 30d bucket = 9000bps
Assert.Equal(90, result1.AdjustedScore); // 100 * 9000 / 10000 = 90
}
[Theory]
[InlineData(5, "fresh_7d", 10000, 100)] // 5 days old
[InlineData(15, "recent_30d", 9000, 90)] // 15 days old
[InlineData(60, "moderate_90d", 7500, 75)] // 60 days old
[InlineData(120, "aging_180d", 6000, 60)] // 120 days old
[InlineData(300, "stale_365d", 4000, 40)] // 300 days old
[InlineData(500, "ancient", 2000, 20)] // 500 days old
public void FreshnessAdjustment_AllBuckets_ApplyCorrectMultiplier(
int ageDays,
string expectedBucket,
int expectedMultiplierBps,
int expectedScore)
{
// Arrange
var evaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
var evidenceTime = evaluationTime.AddDays(-ageDays);
var baseScore = 100;
// Act
var result = _freshnessService.AdjustForFreshness(baseScore, evidenceTime, evaluationTime);
// Assert
Assert.Equal(expectedBucket, result.BucketName);
Assert.Equal(expectedMultiplierBps, result.MultiplierBps);
Assert.Equal(expectedScore, result.AdjustedScore);
}
[Fact]
public void FreshnessAdjustment_FutureEvidence_GetsFreshBucket()
{
// Arrange
var evaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
var evidenceTime = evaluationTime.AddDays(1); // Future evidence
// Act
var result = _freshnessService.AdjustForFreshness(100, evidenceTime, evaluationTime);
// Assert
Assert.Equal("fresh_7d", result.BucketName);
Assert.Equal(10000, result.MultiplierBps);
Assert.Equal(0, result.EvidenceAgeDays);
}
#endregion
#region Bucket Lookup Tests
[Fact]
public void GetFreshnessBucket_ReturnsCorrectPercentage()
{
// Arrange
var evaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
var evidenceTime = evaluationTime.AddDays(-60); // 60 days old
// Act
var result = _freshnessService.GetFreshnessBucket(evidenceTime, evaluationTime);
// Assert
Assert.Equal(60, result.AgeDays);
Assert.Equal("moderate_90d", result.BucketName);
Assert.Equal(7500, result.MultiplierBps);
Assert.Equal(75m, result.MultiplierPercent);
}
#endregion
#region Determinism Tests
[Fact]
public void FreshnessAdjustment_SameInputs_AlwaysProducesSameOutput()
{
// Test determinism across multiple invocations
var evaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
var evidenceTime = evaluationTime.AddDays(-45);
var results = new List<FreshnessAdjustedScore>();
for (int i = 0; i < 100; i++)
{
results.Add(_freshnessService.AdjustForFreshness(85, evidenceTime, evaluationTime));
}
Assert.True(results.All(r => r.AdjustedScore == results[0].AdjustedScore));
Assert.True(results.All(r => r.MultiplierBps == results[0].MultiplierBps));
Assert.True(results.All(r => r.BucketName == results[0].BucketName));
}
[Fact]
public void FreshnessAdjustment_BasisPointMath_AvoidFloatingPointErrors()
{
// Verify integer math produces predictable results
var evaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
var evidenceTime = evaluationTime.AddDays(-45);
// Score that could produce floating point issues if using decimals
var result = _freshnessService.AdjustForFreshness(33, evidenceTime, evaluationTime);
// 33 * 7500 / 10000 = 24.75 -> rounds to 24 with integer division
Assert.Equal(24, result.AdjustedScore);
}
#endregion
#region Edge Cases
[Fact]
public void FreshnessAdjustment_ZeroScore_ReturnsZero()
{
var evaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
var evidenceTime = evaluationTime.AddDays(-30);
var result = _freshnessService.AdjustForFreshness(0, evidenceTime, evaluationTime);
Assert.Equal(0, result.AdjustedScore);
}
[Fact]
public void FreshnessAdjustment_VeryOldEvidence_StillGetsMinMultiplier()
{
var evaluationTime = new DateTimeOffset(2025, 12, 16, 12, 0, 0, TimeSpan.Zero);
var evidenceTime = evaluationTime.AddDays(-3650); // 10 years old
var result = _freshnessService.AdjustForFreshness(100, evidenceTime, evaluationTime);
Assert.Equal("ancient", result.BucketName);
Assert.Equal(2000, result.MultiplierBps); // Minimum multiplier
Assert.Equal(20, result.AdjustedScore);
}
#endregion
}