fix(backend): cap vulnerability_report severity at Warning

A completed scan is a finding about the software inventory, not an
enrollment failure. Mapping the CVE risk onto Error/Critical made the
report read as a failed enrollment: the diagnosis page collected it as
failure evidence, MCP search lifted it on failure-intent queries, and the
timeline's Error filter filled up with clean sessions. critical/high now
map to Warning, everything else to Info; the CVE severity itself stays in
scan_summary and on the report panel's own scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-26 13:54:58 +02:00
parent 058248d95c
commit d89e3f0b48
2 changed files with 47 additions and 4 deletions
@@ -115,7 +115,7 @@ public class VulnerabilityReportEventSequenceTests
Assert.Equal("vulnerability_report", evt.EventType);
Assert.Equal(AutopilotMonitor.Shared.Constants.EventTypes.VulnerabilityReport, evt.EventType);
Assert.Equal("VulnerabilityCorrelationService", evt.Source);
Assert.Equal(EventSeverity.Error, evt.Severity); // risk=high → Error
Assert.Equal(EventSeverity.Warning, evt.Severity); // risk=high → Warning (severity is capped)
// Non-phase event: must stay Unknown so the UI timeline doesn't render a phantom phase boundary.
Assert.Equal(EnrollmentPhase.Unknown, evt.Phase);
Assert.Equal(NowUtc, evt.Timestamp);
@@ -127,6 +127,35 @@ public class VulnerabilityReportEventSequenceTests
Assert.IsType<Dictionary<string, object>>(evt.Data["scan_summary"]);
}
[Theory]
// The scan reports on the software inventory, not on the enrollment: a finding is never an
// enrollment failure, so severity is capped at Warning. Error/Critical would put the report
// into the diagnosis page's failure evidence and into MCP's failure-intent ranking.
[InlineData("critical", EventSeverity.Warning)]
[InlineData("high", EventSeverity.Warning)]
[InlineData("medium", EventSeverity.Info)]
[InlineData("low", EventSeverity.Info)]
[InlineData("none", EventSeverity.Info)]
[InlineData("HIGH", EventSeverity.Warning)] // case-insensitive
[InlineData("", EventSeverity.Info)]
public void Severity_IsCappedAtWarning(string risk, EventSeverity expected)
{
var evt = Build(null, new List<EnrollmentEvent>(), Report(risk));
Assert.Equal(expected, evt.Severity);
}
[Fact]
public void Severity_IsInfo_WhenRiskIsMissing()
{
var report = new Dictionary<string, object>
{
["scan_summary"] = new Dictionary<string, object>(),
};
Assert.Equal(EventSeverity.Info, Build(null, new List<EnrollmentEvent>(), report).Severity);
}
[Fact]
public void Data_Phase_CarriesTheLabel()
{
@@ -541,12 +541,26 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability
string.Equals(phase, "user_enrollment", StringComparison.Ordinal)
|| string.Equals(phase, "both", StringComparison.Ordinal);
/// <summary>
/// Maps the scan's overall CVE risk onto the event severity, capped at
/// <see cref="EventSeverity.Warning"/>.
/// <para>
/// <b>Why the cap:</b> <c>EventSeverity</c> on the timeline states how badly the
/// <i>enrollment</i> went, not how severe a CVE is. A completed scan is a finding about
/// the software inventory — nothing failed. Emitting Error/Critical made the report read
/// as an enrollment failure: the diagnosis page collects Error/Critical events as failure
/// evidence, MCP search lifts high-severity events on failure-intent queries, and the
/// timeline's Error filter — the one used to hunt for problems — filled up with clean
/// sessions. The CVE severity itself is not lost: it rides in <c>scan_summary</c>
/// (<c>overall_risk</c>, <c>critical_cves</c>, …) and the report panel renders it on its
/// own colour scale. Warning keeps the report visible without claiming a failure.
/// </para>
/// </summary>
private static EventSeverity MapVulnerabilityRiskToSeverity(string? overallRisk) =>
(overallRisk ?? string.Empty).ToLowerInvariant() switch
{
"critical" => EventSeverity.Critical,
"high" => EventSeverity.Error,
"medium" => EventSeverity.Warning,
"critical" => EventSeverity.Warning,
"high" => EventSeverity.Warning,
_ => EventSeverity.Info,
};
}