349 lines
9.4 KiB
C#
349 lines
9.4 KiB
C#
// Licensed to StellaOps under the BUSL-1.1 license.
|
|
|
|
|
|
using StellaOps.VexLens.Models;
|
|
using System.Collections.Immutable;
|
|
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace StellaOps.VexLens.Delta;
|
|
|
|
/// <summary>
|
|
/// Options for delta report generation.
|
|
/// </summary>
|
|
public sealed record DeltaReportOptions
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the confidence change threshold for triggering ConfidenceUp/Down sections.
|
|
/// </summary>
|
|
public double ConfidenceChangeThreshold { get; init; } = 0.15;
|
|
|
|
/// <summary>
|
|
/// Gets or sets whether to include damped entries in the report.
|
|
/// </summary>
|
|
public bool IncludeDamped { get; init; } = false;
|
|
|
|
/// <summary>
|
|
/// Gets or sets whether to include evidence-only changes.
|
|
/// </summary>
|
|
public bool IncludeEvidenceChanges { get; init; } = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builder for creating <see cref="DeltaReport"/> instances.
|
|
/// </summary>
|
|
public sealed class DeltaReportBuilder
|
|
{
|
|
private readonly List<DeltaEntry> _entries = new();
|
|
private readonly TimeProvider _timeProvider;
|
|
private string _fromDigest = string.Empty;
|
|
private string _toDigest = string.Empty;
|
|
private DeltaReportOptions _options = new();
|
|
|
|
/// <summary>
|
|
/// Initializes a new delta report builder.
|
|
/// </summary>
|
|
public DeltaReportBuilder(TimeProvider? timeProvider = null)
|
|
{
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the snapshot digests.
|
|
/// </summary>
|
|
public DeltaReportBuilder WithSnapshots(string fromDigest, string toDigest)
|
|
{
|
|
_fromDigest = fromDigest;
|
|
_toDigest = toDigest;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the report options.
|
|
/// </summary>
|
|
public DeltaReportBuilder WithOptions(DeltaReportOptions options)
|
|
{
|
|
_options = options;
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a new finding entry.
|
|
/// </summary>
|
|
public DeltaReportBuilder AddNew(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
VexStatus status,
|
|
double confidence,
|
|
string? rationaleClass = null,
|
|
VexJustification? justification = null,
|
|
IEnumerable<string>? sources = null)
|
|
{
|
|
var entry = CreateEntry(
|
|
vulnerabilityId,
|
|
productKey,
|
|
DeltaSection.New,
|
|
null,
|
|
status,
|
|
null,
|
|
confidence,
|
|
null,
|
|
rationaleClass,
|
|
justification,
|
|
$"New {status} finding",
|
|
sources);
|
|
|
|
_entries.Add(entry);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a resolved finding entry.
|
|
/// </summary>
|
|
public DeltaReportBuilder AddResolved(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
VexStatus fromStatus,
|
|
VexStatus toStatus,
|
|
double fromConfidence,
|
|
double toConfidence,
|
|
VexJustification? justification = null,
|
|
IEnumerable<string>? sources = null)
|
|
{
|
|
var entry = CreateEntry(
|
|
vulnerabilityId,
|
|
productKey,
|
|
DeltaSection.Resolved,
|
|
fromStatus,
|
|
toStatus,
|
|
fromConfidence,
|
|
toConfidence,
|
|
null,
|
|
null,
|
|
justification,
|
|
$"Resolved: {fromStatus} -> {toStatus}",
|
|
sources);
|
|
|
|
_entries.Add(entry);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a confidence change entry.
|
|
/// </summary>
|
|
public DeltaReportBuilder AddConfidenceChange(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
VexStatus status,
|
|
double fromConfidence,
|
|
double toConfidence,
|
|
IEnumerable<string>? sources = null)
|
|
{
|
|
var delta = toConfidence - fromConfidence;
|
|
var section = delta > 0 ? DeltaSection.ConfidenceUp : DeltaSection.ConfidenceDown;
|
|
|
|
if (Math.Abs(delta) < _options.ConfidenceChangeThreshold)
|
|
{
|
|
return this; // Below threshold, don't add
|
|
}
|
|
|
|
var entry = CreateEntry(
|
|
vulnerabilityId,
|
|
productKey,
|
|
section,
|
|
status,
|
|
status,
|
|
fromConfidence,
|
|
toConfidence,
|
|
null,
|
|
null,
|
|
null,
|
|
string.Create(CultureInfo.InvariantCulture,
|
|
$"Confidence {(delta > 0 ? "increased" : "decreased")}: {fromConfidence:P0} -> {toConfidence:P0}"),
|
|
sources);
|
|
|
|
_entries.Add(entry);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a policy impact entry.
|
|
/// </summary>
|
|
public DeltaReportBuilder AddPolicyImpact(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
VexStatus status,
|
|
double confidence,
|
|
string impactDescription,
|
|
IEnumerable<string>? sources = null)
|
|
{
|
|
var entry = CreateEntry(
|
|
vulnerabilityId,
|
|
productKey,
|
|
DeltaSection.PolicyImpact,
|
|
status,
|
|
status,
|
|
confidence,
|
|
confidence,
|
|
null,
|
|
null,
|
|
null,
|
|
$"Policy impact: {impactDescription}",
|
|
sources);
|
|
|
|
_entries.Add(entry);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a damped (suppressed) entry.
|
|
/// </summary>
|
|
public DeltaReportBuilder AddDamped(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
VexStatus fromStatus,
|
|
VexStatus toStatus,
|
|
double fromConfidence,
|
|
double toConfidence,
|
|
string dampReason)
|
|
{
|
|
if (!_options.IncludeDamped)
|
|
{
|
|
return this;
|
|
}
|
|
|
|
var entry = CreateEntry(
|
|
vulnerabilityId,
|
|
productKey,
|
|
DeltaSection.Damped,
|
|
fromStatus,
|
|
toStatus,
|
|
fromConfidence,
|
|
toConfidence,
|
|
null,
|
|
null,
|
|
null,
|
|
$"Damped: {dampReason}",
|
|
null);
|
|
|
|
_entries.Add(entry);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an evidence change entry.
|
|
/// </summary>
|
|
public DeltaReportBuilder AddEvidenceChange(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
VexStatus status,
|
|
double confidence,
|
|
string fromRationaleClass,
|
|
string toRationaleClass,
|
|
IEnumerable<string>? sources = null)
|
|
{
|
|
if (!_options.IncludeEvidenceChanges)
|
|
{
|
|
return this;
|
|
}
|
|
|
|
var entry = CreateEntry(
|
|
vulnerabilityId,
|
|
productKey,
|
|
DeltaSection.EvidenceChanged,
|
|
status,
|
|
status,
|
|
confidence,
|
|
confidence,
|
|
fromRationaleClass,
|
|
toRationaleClass,
|
|
null,
|
|
$"Evidence changed: {fromRationaleClass} -> {toRationaleClass}",
|
|
sources);
|
|
|
|
_entries.Add(entry);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the delta report.
|
|
/// </summary>
|
|
public DeltaReport Build()
|
|
{
|
|
var now = _timeProvider.GetUtcNow();
|
|
var reportId = GenerateReportId(now);
|
|
|
|
// Sort entries for deterministic output
|
|
var sortedEntries = _entries
|
|
.OrderBy(e => (int)e.Section)
|
|
.ThenBy(e => e.VulnerabilityId, StringComparer.Ordinal)
|
|
.ThenBy(e => e.ProductKey, StringComparer.Ordinal)
|
|
.ToImmutableArray();
|
|
|
|
return new DeltaReport
|
|
{
|
|
ReportId = reportId,
|
|
FromSnapshotDigest = _fromDigest,
|
|
ToSnapshotDigest = _toDigest,
|
|
GeneratedAt = now,
|
|
Entries = sortedEntries,
|
|
Summary = DeltaSummary.FromEntries(sortedEntries)
|
|
};
|
|
}
|
|
|
|
private DeltaEntry CreateEntry(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
DeltaSection section,
|
|
VexStatus? fromStatus,
|
|
VexStatus toStatus,
|
|
double? fromConfidence,
|
|
double toConfidence,
|
|
string? fromRationaleClass,
|
|
string? toRationaleClass,
|
|
VexJustification? justification,
|
|
string summary,
|
|
IEnumerable<string>? sources)
|
|
{
|
|
var now = _timeProvider.GetUtcNow();
|
|
var deltaId = ComputeDeltaId(vulnerabilityId, productKey, section, now);
|
|
|
|
return new DeltaEntry
|
|
{
|
|
DeltaId = deltaId,
|
|
VulnerabilityId = vulnerabilityId,
|
|
ProductKey = productKey,
|
|
Section = section,
|
|
FromStatus = fromStatus,
|
|
ToStatus = toStatus,
|
|
FromConfidence = fromConfidence,
|
|
ToConfidence = toConfidence,
|
|
FromRationaleClass = fromRationaleClass,
|
|
ToRationaleClass = toRationaleClass,
|
|
Justification = justification,
|
|
Summary = summary,
|
|
Timestamp = now,
|
|
ContributingSources = sources?.ToImmutableArray() ?? []
|
|
};
|
|
}
|
|
|
|
private static string ComputeDeltaId(
|
|
string vulnerabilityId,
|
|
string productKey,
|
|
DeltaSection section,
|
|
DateTimeOffset timestamp)
|
|
{
|
|
var input = $"{vulnerabilityId}|{productKey}|{section}|{timestamp:O}";
|
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
|
return Convert.ToHexStringLower(hash)[..16];
|
|
}
|
|
|
|
private string GenerateReportId(DateTimeOffset timestamp)
|
|
{
|
|
var input = $"{_fromDigest}|{_toDigest}|{timestamp:O}";
|
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
|
return $"delta-{Convert.ToHexStringLower(hash)[..12]}";
|
|
}
|
|
}
|