From 9f3aa8f886442436cd9e51f49949cc1324df4ec8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:38:08 +0000 Subject: [PATCH] Lead the vulnerability card with an actionability statement The card now opens with a line that says whether updating the image helps: a warning-toned "X of Y findings can be fixed by updating this image" when findings are fixable, and a neutral "Nothing to fix right now" statement when no upstream fix exists. The separate fixable-count line is absorbed into it, and the persisted assessment message is only rendered for statuses other than "Findings detected" and "No findings" so provider errors stay visible while the redundant "reported N finding(s)" text disappears. Adds the summary-remediation classes with a dark-mode tone override. Closes #208 --- .../Shared/VulnerabilityAssessmentCard.razor | 10 +-- .../VulnerabilityAssessmentCard.razor.cs | 74 ++++++++++++++++++ src/DockerUpdateGuard/wwwroot/app.css | 18 +++++ .../MyImageDetailTests.cs | 6 +- .../ObservedImageDetailTests.cs | 4 +- .../RuntimeContainerDetailTests.cs | 6 +- .../VulnerabilityAssessmentCardTests.cs | 75 ++++++++++++++++++- 7 files changed, 177 insertions(+), 16 deletions(-) diff --git a/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor b/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor index f5847f8..1edffae 100644 --- a/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor +++ b/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor @@ -2,6 +2,10 @@ { Vulnerability assessment + @if (RemediationSummary is not null) + { + @RemediationSummary + } @Assessment.Status @if (Assessment.NewFindingCount > 0) @@ -13,15 +17,11 @@ @($"−{Assessment.ResolvedFindingCount} resolved") } @($"{Assessment.Source} · {Assessment.ActiveFindingCount} active") - @if (Assessment.ActiveFindingCount > 0) - { - @($"{Assessment.FixableFindingCount} of {Assessment.ActiveFindingCount} active findings have a fix available") - } @if (ShowCheckedAt && Assessment.CheckedAtUtc is DateTimeOffset checkedAtUtc) { Checked @checkedAtUtc.ToLocalTime().ToString("g") } - @if (string.IsNullOrWhiteSpace(Assessment.Message) == false) + @if (ShowMessage) { @Assessment.Message } diff --git a/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor.cs b/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor.cs index 6a0fa67..bf4685d 100644 --- a/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor.cs +++ b/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor.cs @@ -9,6 +9,20 @@ namespace DockerUpdateGuard.Components.Shared; /// public partial class VulnerabilityAssessmentCard { + #region Constants + + /// + /// Status label of an assessment that reported findings + /// + private const string FindingsDetectedStatus = "Findings detected"; + + /// + /// Status label of an assessment that reported no findings + /// + private const string NoFindingsStatus = "No findings"; + + #endregion // Constants + #region Properties /// @@ -23,5 +37,65 @@ public partial class VulnerabilityAssessmentCard [Parameter] public bool ShowCheckedAt { get; set; } + /// + /// Leading line that states whether updating the image can remedy the known findings, + /// or null when the assessment has no active findings + /// + private string? RemediationSummary + { + get + { + if (Assessment is null || Assessment.ActiveFindingCount == 0) + { + return null; + } + + if (Assessment.FixableFindingCount > 0) + { + return $"{Assessment.FixableFindingCount} of {Assessment.ActiveFindingCount} findings can be fixed by updating this image"; + } + + return $"Nothing to fix right now — no upstream fix is available for the {Assessment.ActiveFindingCount} known issues in this image"; + } + } + + /// + /// CSS classes of the leading remediation line, using the warning tone only when an update actually helps + /// + private string RemediationCssClass + { + get + { + if (Assessment is not null && Assessment.FixableFindingCount > 0) + { + return "summary-remediation summary-remediation--warning"; + } + + return "summary-remediation"; + } + } + + /// + /// Whether the persisted assessment message is rendered, which is suppressed for the + /// completed scan states because there the message only restates the finding count + /// + private bool ShowMessage + { + get + { + if (Assessment is null || string.IsNullOrWhiteSpace(Assessment.Message) == true) + { + return false; + } + + if (string.Equals(Assessment.Status, FindingsDetectedStatus, StringComparison.OrdinalIgnoreCase) == true) + { + return false; + } + + return string.Equals(Assessment.Status, NoFindingsStatus, StringComparison.OrdinalIgnoreCase) == false; + } + } + #endregion // Properties } \ No newline at end of file diff --git a/src/DockerUpdateGuard/wwwroot/app.css b/src/DockerUpdateGuard/wwwroot/app.css index 6b70304..4cf0ca8 100644 --- a/src/DockerUpdateGuard/wwwroot/app.css +++ b/src/DockerUpdateGuard/wwwroot/app.css @@ -540,6 +540,24 @@ a { overflow-wrap: anywhere; } +.summary-remediation { + display: block; + margin: 0.35rem 0 0.55rem; + color: var(--dug-text-muted); + font-size: 0.88rem; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.summary-remediation--warning { + color: var(--dug-warning); +} + +.dug-dark .summary-remediation--warning { + color: #f59e0b; +} + .dug-chart { position: relative; min-height: 200px; diff --git a/src/Tests/DockerUpdateGuard.Tests/MyImageDetailTests.cs b/src/Tests/DockerUpdateGuard.Tests/MyImageDetailTests.cs index 6f263f8..eafa531 100644 --- a/src/Tests/DockerUpdateGuard.Tests/MyImageDetailTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/MyImageDetailTests.cs @@ -101,7 +101,7 @@ public async Task MyImageDetailWithFixableFindingsShowsFixableSummaryAndUpdateHi var component = testContext.Render(parameters => parameters.Add(page => page.ObservedImageId, observedImageId)); - Assert.Contains("1 of 2 active findings have a fix available", + Assert.Contains("1 of 2 findings can be fixed by updating this image", component.Markup, "The vulnerability assessment card must show the fixable finding count"); Assert.Contains("Updating may resolve up to 1 of 2 active findings", @@ -150,9 +150,9 @@ public async Task MyImageDetailWithoutFixableFindingsHidesUpdateHint() var component = testContext.Render(parameters => parameters.Add(page => page.ObservedImageId, observedImageId)); - Assert.DoesNotContain("active findings have a fix available", + Assert.DoesNotContain("summary-remediation", component.Markup, - "The fixable summary line must be hidden when there are no active findings"); + "The remediation line must be hidden when there are no active findings"); Assert.DoesNotContain("Updating may resolve", component.Markup, "The update findings hint chip must be hidden when no active finding has a fix available"); diff --git a/src/Tests/DockerUpdateGuard.Tests/ObservedImageDetailTests.cs b/src/Tests/DockerUpdateGuard.Tests/ObservedImageDetailTests.cs index 843ac88..c04b26d 100644 --- a/src/Tests/DockerUpdateGuard.Tests/ObservedImageDetailTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/ObservedImageDetailTests.cs @@ -58,7 +58,7 @@ public async Task ObservedImageDetailWithFixableFindingsShowsFixableSummaryAndUp var component = testContext.Render(parameters => parameters.Add(page => page.ObservedImageId, observedImageId)); - Assert.Contains("2 of 3 active findings have a fix available", + Assert.Contains("2 of 3 findings can be fixed by updating this image", component.Markup, "The vulnerability assessment card must show the fixable finding count"); Assert.Contains("Updating may resolve up to 2 of 3 active findings", @@ -100,7 +100,7 @@ public async Task ObservedImageDetailWithoutActiveUpdateFindingsHidesUpdateHint( var component = testContext.Render(parameters => parameters.Add(page => page.ObservedImageId, observedImageId)); - Assert.Contains("1 of 1 active findings have a fix available", + Assert.Contains("1 of 1 findings can be fixed by updating this image", component.Markup, "The vulnerability assessment card must still show the fixable finding count"); Assert.DoesNotContain("Updating may resolve", diff --git a/src/Tests/DockerUpdateGuard.Tests/RuntimeContainerDetailTests.cs b/src/Tests/DockerUpdateGuard.Tests/RuntimeContainerDetailTests.cs index c90cb13..0201ed5 100644 --- a/src/Tests/DockerUpdateGuard.Tests/RuntimeContainerDetailTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/RuntimeContainerDetailTests.cs @@ -66,7 +66,7 @@ public async Task RuntimeContainerDetailWithFixableFindingsShowsFixableSummaryAn var component = testContext.Render(parameters => parameters.Add(page => page.DockerInstanceId, dockerInstanceId) .Add(page => page.ContainerId, containerId)); - Assert.Contains("1 of 2 active findings have a fix available", + Assert.Contains("1 of 2 findings can be fixed by updating this image", component.Markup, "The vulnerability assessment card must show the fixable finding count"); Assert.Contains("Updating may resolve up to 1 of 2 active findings", @@ -123,9 +123,9 @@ public async Task RuntimeContainerDetailWithoutFixableFindingsHidesFixableSummar var component = testContext.Render(parameters => parameters.Add(page => page.DockerInstanceId, dockerInstanceId) .Add(page => page.ContainerId, containerId)); - Assert.DoesNotContain("active findings have a fix available", + Assert.DoesNotContain("summary-remediation", component.Markup, - "The fixable summary line must be hidden when there are no active findings"); + "The remediation line must be hidden when there are no active findings"); Assert.DoesNotContain("Updating may resolve", component.Markup, "The update hint chip must be hidden when no active finding has a fix available"); diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs index cf5a1f4..102d9a8 100644 --- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs @@ -63,8 +63,76 @@ public async Task VulnerabilityAssessmentCardPopulatedAssessmentRendersStatusChi Assert.Contains("+2 new", markup, "The card must render the new finding count"); Assert.Contains("−3 resolved", markup, "The card must render the resolved finding count"); Assert.Contains("Trivy · 6 active", markup, "The card must render the source and the active finding count"); - Assert.Contains("4 of 6 active findings have a fix available", markup, "The card must render the fixable finding summary"); - Assert.Contains("Scan completed with findings", markup, "The card must render the assessment message"); + Assert.Contains("4 of 6 findings can be fixed by updating this image", markup, "The card must lead with the fixable finding statement"); + Assert.DoesNotContain("Scan completed with findings", markup, "The card must suppress the provider message for a completed scan with findings"); + } + } + + /// + /// Verify the leading line names the fixable findings and uses the warning tone + /// + /// Task + [TestMethod] + public async Task VulnerabilityAssessmentCardFixableFindingsRendersWarningRemediationLine() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var component = testContext.Render(parameters => parameters.Add(card => card.Assessment, CreateAssessment())); + var markup = component.Markup; + + Assert.Contains("4 of 6 findings can be fixed by updating this image", markup, "The card must state how many findings an update fixes"); + Assert.Contains("summary-remediation--warning", markup, "The fixable statement must use the warning tone"); + Assert.DoesNotContain("Nothing to fix right now", markup, "The card must not state that nothing can be fixed while findings are fixable"); + } + } + + /// + /// Verify the leading line reports the neutral no-upstream-fix statement when nothing is fixable + /// + /// Task + [TestMethod] + public async Task VulnerabilityAssessmentCardWithoutFixableFindingsRendersNeutralRemediationLine() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var assessment = CreateAssessment(); + assessment.FixableFindingCount = 0; + + var component = testContext.Render(parameters => parameters.Add(card => card.Assessment, assessment)); + var markup = component.Markup; + + Assert.Contains("Nothing to fix right now — no upstream fix is available for the 6 known issues in this image", + markup, + "The card must state that no upstream fix is available"); + Assert.DoesNotContain("summary-remediation--warning", markup, "The neutral statement must not use the warning tone"); + } + } + + /// + /// Verify a failed assessment still renders the message of the vulnerability provider + /// + /// Task + [TestMethod] + public async Task VulnerabilityAssessmentCardFailedAssessmentRendersProviderMessage() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var assessment = new VulnerabilityAssessmentViewData + { + Status = "Failed", + Source = "Trivy", + Message = "The vulnerability scan timed out", + }; + + var component = testContext.Render(parameters => parameters.Add(card => card.Assessment, assessment)); + + Assert.Contains("The vulnerability scan timed out", component.Markup, "A failed assessment must still render the provider message"); } } @@ -129,7 +197,8 @@ public async Task VulnerabilityAssessmentCardEmptyAssessmentOmitsOptionalLines() Assert.Contains("Trivy · 0 active", markup, "The card must render the source and the active finding count"); Assert.DoesNotContain("new", markup, "The card must omit the new finding chip when no findings are new"); Assert.DoesNotContain("resolved", markup, "The card must omit the resolved finding chip when no findings were resolved"); - Assert.DoesNotContain("have a fix available", markup, "The card must omit the fixable summary when there are no active findings"); + Assert.DoesNotContain("summary-remediation", markup, "The card must omit the remediation statement when there are no active findings"); + Assert.DoesNotContain("Nothing to fix right now", markup, "The card must omit the remediation statement when there are no active findings"); Assert.DoesNotContain("Checked ", markup, "The card must omit the check timestamp when it is not set"); } }