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"); } }