From 265c457b9160ce0295027e9699b303ee46ac8c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:39:27 +0000 Subject: [PATCH 1/2] Capture Trivy fix status and package class on vulnerability findings Trivy reports a per-vulnerability Status and a per-result-block Class and Type, all of which were discarded on parse. The Trivy payload records now carry these values, the provider projects each vulnerability together with its owning result block so the block-level classification is attributed correctly, and the values flow through the advisory transport into the persisted finding via migration Update7. Unknown or missing statuses map to the new VulnerabilityFixStatus.NotSet, and the finding identity remains AdvisoryId plus AffectedPackage so a re-scan does not duplicate rows. Refs #207 --- .../VulnerabilityFindingConfiguration.cs | 9 + .../Entities/VulnerabilityFinding.cs | 15 + .../Entities/VulnerabilityFixStatus.cs | 37 + ...DockerUpdateGuardDbContextModelSnapshot.cs | 11 + .../Migrations/Update7.Designer.cs | 1099 +++++++++++++++++ .../Migrations/Update7.cs | 48 + .../Images/VulnerabilityEnrichmentService.cs | 3 + .../Vulnerabilities/Data/TrivyResult.cs | 18 + .../Data/TrivyVulnerability.cs | 6 + .../TrivyVulnerabilityProvider.cs | 62 +- .../VulnerabilityAdvisoryData.cs | 15 + .../TrivyVulnerabilityProviderTests.cs | 175 +++ .../VulnerabilityEnrichmentServiceTests.cs | 138 +++ 13 files changed, 1623 insertions(+), 13 deletions(-) create mode 100644 src/DockerUpdateGuard.Data/Entities/VulnerabilityFixStatus.cs create mode 100644 src/DockerUpdateGuard.Data/Migrations/Update7.Designer.cs create mode 100644 src/DockerUpdateGuard.Data/Migrations/Update7.cs diff --git a/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs b/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs index 5639cb5..454d825 100644 --- a/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs +++ b/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs @@ -50,6 +50,15 @@ public void Configure(EntityTypeBuilder builder) builder.Property(entity => entity.Source) .IsRequired(); + builder.Property(entity => entity.FixStatus) + .IsRequired(); + + builder.Property(entity => entity.PackageClass) + .HasMaxLength(50); + + builder.Property(entity => entity.PackageType) + .HasMaxLength(50); + builder.Property(entity => entity.DetectedAtUtc) .IsRequired(); diff --git a/src/DockerUpdateGuard.Data/Entities/VulnerabilityFinding.cs b/src/DockerUpdateGuard.Data/Entities/VulnerabilityFinding.cs index 74c9856..a2c9369 100644 --- a/src/DockerUpdateGuard.Data/Entities/VulnerabilityFinding.cs +++ b/src/DockerUpdateGuard.Data/Entities/VulnerabilityFinding.cs @@ -62,6 +62,21 @@ public class VulnerabilityFinding /// public VulnerabilitySource Source { get; set; } + /// + /// Fix status reported by the provider + /// + public VulnerabilityFixStatus FixStatus { get; set; } + + /// + /// Optional package class the finding belongs to (e.g. os-pkgs) + /// + public string? PackageClass { get; set; } + + /// + /// Optional package type the finding belongs to (e.g. alpine) + /// + public string? PackageType { get; set; } + /// /// Optional CVSS score /// diff --git a/src/DockerUpdateGuard.Data/Entities/VulnerabilityFixStatus.cs b/src/DockerUpdateGuard.Data/Entities/VulnerabilityFixStatus.cs new file mode 100644 index 0000000..0bfb709 --- /dev/null +++ b/src/DockerUpdateGuard.Data/Entities/VulnerabilityFixStatus.cs @@ -0,0 +1,37 @@ +namespace DockerUpdateGuard.Data.Entities; + +/// +/// Fix status a vulnerability provider reported for a finding +/// +public enum VulnerabilityFixStatus +{ + /// + /// No fix status set, the provider did not report one + /// + NotSet = 0, + + /// + /// A fixed package version is available upstream + /// + Fixed = 1, + + /// + /// The package is affected and no fix has been published yet + /// + Affected = 2, + + /// + /// Upstream declared that the vulnerability will not be fixed + /// + WillNotFix = 3, + + /// + /// Upstream deferred the fix to a later point in time + /// + FixDeferred = 4, + + /// + /// The affected package has reached its end of life and receives no fix + /// + EndOfLife = 5 +} \ No newline at end of file diff --git a/src/DockerUpdateGuard.Data/Migrations/DockerUpdateGuardDbContextModelSnapshot.cs b/src/DockerUpdateGuard.Data/Migrations/DockerUpdateGuardDbContextModelSnapshot.cs index fd26ca8..1b768fc 100644 --- a/src/DockerUpdateGuard.Data/Migrations/DockerUpdateGuardDbContextModelSnapshot.cs +++ b/src/DockerUpdateGuard.Data/Migrations/DockerUpdateGuardDbContextModelSnapshot.cs @@ -715,6 +715,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DetectedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("FixStatus") + .HasColumnType("integer"); + b.Property("FixedVersion") .HasMaxLength(100) .HasColumnType("character varying(100)"); @@ -729,6 +732,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); + b.Property("PackageClass") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PackageType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + b.Property("ReferenceUrl") .HasColumnType("text"); diff --git a/src/DockerUpdateGuard.Data/Migrations/Update7.Designer.cs b/src/DockerUpdateGuard.Data/Migrations/Update7.Designer.cs new file mode 100644 index 0000000..9b90a5d --- /dev/null +++ b/src/DockerUpdateGuard.Data/Migrations/Update7.Designer.cs @@ -0,0 +1,1099 @@ +// +using System; +using DockerUpdateGuard.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DockerUpdateGuard.Data.Migrations +{ + [DbContext(typeof(DockerUpdateGuardDbContext))] + [Migration("20260727173517_Update7")] + partial class Update7 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ContainerActionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ContainerSnapshotId") + .HasColumnType("uuid"); + + b.Property("DockerInstanceId") + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("PortainerEndpointId") + .HasColumnType("uuid"); + + b.Property("PortainerTaskId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestedBy") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ResourceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResourceType") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContainerSnapshotId"); + + b.HasIndex("DockerInstanceId", "RequestedAtUtc"); + + b.HasIndex("PortainerEndpointId", "RequestedAtUtc"); + + b.ToTable("ContainerActionRuns", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ContainerSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvailableUpdateVersionTag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ComposeProject") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ContainerId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DockerInstanceId") + .HasColumnType("uuid"); + + b.Property("ImageVersionId") + .HasColumnType("uuid"); + + b.Property("IsRunning") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedVersionTag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ScanRunId") + .HasColumnType("uuid"); + + b.Property("ServiceName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StackName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateAssessmentMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("UpdateAssessmentStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImageVersionId"); + + b.HasIndex("ScanRunId"); + + b.HasIndex("DockerInstanceId", "ContainerId", "RecordedAtUtc"); + + b.ToTable("ContainerSnapshots", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.DockerInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConnectionKind") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndpointUri") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SkipCertificateValidation") + .HasColumnType("boolean"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DockerInstances", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.DockerInstanceResourceSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContainerCount") + .HasColumnType("integer"); + + b.Property("CpuPercent") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("DockerInstanceId") + .HasColumnType("uuid"); + + b.Property("MemoryLimitBytes") + .HasColumnType("bigint"); + + b.Property("MemoryUsageBytes") + .HasColumnType("bigint"); + + b.Property("NetworkRxBytesPerSecond") + .HasPrecision(20, 4) + .HasColumnType("numeric(20,4)"); + + b.Property("NetworkRxBytesTotal") + .HasColumnType("bigint"); + + b.Property("NetworkTxBytesPerSecond") + .HasPrecision(20, 4) + .HasColumnType("numeric(20,4)"); + + b.Property("NetworkTxBytesTotal") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DockerInstanceId", "RecordedAtUtc"); + + b.ToTable("DockerInstanceResourceSamples", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ImageRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseImageVersionId") + .HasColumnType("uuid"); + + b.Property("ChildImageVersionId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Depth") + .HasColumnType("integer"); + + b.Property("RelationshipType") + .HasColumnType("integer"); + + b.Property("ScanRunId") + .HasColumnType("uuid"); + + b.Property("SourceReference") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.HasKey("Id"); + + b.HasIndex("BaseImageVersionId"); + + b.HasIndex("ChildImageVersionId"); + + b.HasIndex("ScanRunId"); + + b.HasIndex("ChildImageVersionId", "BaseImageVersionId", "Depth", "RelationshipType") + .IsUnique(); + + b.ToTable("ImageRelationships", null, t => + { + t.HasCheckConstraint("CK_ImageRelationships_ChildAndBaseDifferent", "\"ChildImageVersionId\" <> \"BaseImageVersionId\""); + + t.HasCheckConstraint("CK_ImageRelationships_DepthGreaterThanZero", "\"Depth\" > 0"); + }); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ImageVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Digest") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("MetadataJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistryRepositoryId") + .HasColumnType("uuid"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("VulnerabilityAssessmentCheckedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("VulnerabilityAssessmentMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("VulnerabilityAssessmentScanRunId") + .HasColumnType("uuid"); + + b.Property("VulnerabilityAssessmentSource") + .HasColumnType("integer"); + + b.Property("VulnerabilityAssessmentStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RegistryRepositoryId", "Tag", "Digest") + .IsUnique(); + + b.ToTable("ImageVersions", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ObservedImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentImageVersionId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrentImageVersionId"); + + b.ToTable("ObservedImages", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.PortainerEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DockerInstanceId") + .HasColumnType("uuid"); + + b.Property("ExternalEndpointId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DockerInstanceId") + .IsUnique(); + + b.ToTable("PortainerEndpoints", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.RegistryRepository", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Registry") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Repository") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Registry", "Repository") + .IsUnique(); + + b.ToTable("RegistryRepositories", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.RuntimeContainerResourceSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContainerId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ContainerName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CpuPercent") + .HasPrecision(10, 4) + .HasColumnType("numeric(10,4)"); + + b.Property("DockerInstanceId") + .HasColumnType("uuid"); + + b.Property("MemoryLimitBytes") + .HasColumnType("bigint"); + + b.Property("MemoryUsageBytes") + .HasColumnType("bigint"); + + b.Property("NetworkRxBytesPerSecond") + .HasPrecision(20, 4) + .HasColumnType("numeric(20,4)"); + + b.Property("NetworkRxBytesTotal") + .HasColumnType("bigint"); + + b.Property("NetworkTxBytesPerSecond") + .HasPrecision(20, 4) + .HasColumnType("numeric(20,4)"); + + b.Property("NetworkTxBytesTotal") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DockerInstanceId", "ContainerId", "RecordedAtUtc"); + + b.ToTable("RuntimeContainerResourceSamples", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.RuntimeContainerTagSelection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContainerId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Digest") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DockerInstanceId") + .HasColumnType("uuid"); + + b.Property("RegistryRepositoryId") + .HasColumnType("uuid"); + + b.Property("SelectedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("RegistryRepositoryId"); + + b.HasIndex("DockerInstanceId", "ContainerId") + .IsUnique(); + + b.ToTable("RuntimeContainerTagSelections", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ScanRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DiagnosticJson") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("DockerInstanceId") + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("ObservedImageId") + .HasColumnType("uuid"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TriggerSource") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DockerInstanceId"); + + b.HasIndex("ObservedImageId"); + + b.HasIndex("Type", "Status", "StartedAtUtc"); + + b.ToTable("ScanRuns", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.TagCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Digest") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("PublishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Tag") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdateFindingId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UpdateFindingId", "Tag", "Digest") + .IsUnique(); + + b.ToTable("TagCandidates", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.UpdateFinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContainerSnapshotId") + .HasColumnType("uuid"); + + b.Property("Details") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DetectedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("ObservedImageId") + .HasColumnType("uuid"); + + b.Property("RecommendedImageVersionId") + .HasColumnType("uuid"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ScanRunId") + .HasColumnType("uuid"); + + b.Property("SubjectImageVersionId") + .HasColumnType("uuid"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RecommendedImageVersionId"); + + b.HasIndex("ScanRunId"); + + b.HasIndex("ContainerSnapshotId", "IsActive"); + + b.HasIndex("ObservedImageId", "IsActive"); + + b.HasIndex("SubjectImageVersionId", "IsActive"); + + b.ToTable("UpdateFindings", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.VulnerabilityFinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdvisoryId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AffectedPackage") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CvssScore") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("DetectedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FixStatus") + .HasColumnType("integer"); + + b.Property("FixedVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ImageVersionId") + .HasColumnType("uuid"); + + b.Property("InstalledVersion") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PackageClass") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PackageType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ReferenceUrl") + .HasColumnType("text"); + + b.Property("ResolvedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ResolvedByScanRunId") + .HasColumnType("uuid"); + + b.Property("ScanRunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.HasKey("Id"); + + b.HasIndex("ResolvedByScanRunId"); + + b.HasIndex("ScanRunId"); + + b.HasIndex("ImageVersionId", "AdvisoryId", "AffectedPackage") + .IsUnique() + .HasFilter("\"IsActive\""); + + b.HasIndex("ImageVersionId", "Severity", "IsActive"); + + b.ToTable("VulnerabilityFindings", (string)null); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ContainerActionRun", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.ContainerSnapshot", "ContainerSnapshot") + .WithMany("ContainerActionRuns") + .HasForeignKey("ContainerSnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DockerUpdateGuard.Data.Entities.DockerInstance", "DockerInstance") + .WithMany("ContainerActionRuns") + .HasForeignKey("DockerInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DockerUpdateGuard.Data.Entities.PortainerEndpoint", "PortainerEndpoint") + .WithMany("ContainerActionRuns") + .HasForeignKey("PortainerEndpointId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ContainerSnapshot"); + + b.Navigation("DockerInstance"); + + b.Navigation("PortainerEndpoint"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ContainerSnapshot", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.DockerInstance", "DockerInstance") + .WithMany("ContainerSnapshots") + .HasForeignKey("DockerInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DockerUpdateGuard.Data.Entities.ImageVersion", "ImageVersion") + .WithMany("ContainerSnapshots") + .HasForeignKey("ImageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DockerUpdateGuard.Data.Entities.ScanRun", "ScanRun") + .WithMany("ContainerSnapshots") + .HasForeignKey("ScanRunId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DockerInstance"); + + b.Navigation("ImageVersion"); + + b.Navigation("ScanRun"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.DockerInstanceResourceSample", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.DockerInstance", "DockerInstance") + .WithMany() + .HasForeignKey("DockerInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DockerInstance"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ImageRelationship", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.ImageVersion", "BaseImageVersion") + .WithMany("BaseRelationships") + .HasForeignKey("BaseImageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("DockerUpdateGuard.Data.Entities.ImageVersion", "ChildImageVersion") + .WithMany("ChildRelationships") + .HasForeignKey("ChildImageVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DockerUpdateGuard.Data.Entities.ScanRun", "ScanRun") + .WithMany("ImageRelationships") + .HasForeignKey("ScanRunId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseImageVersion"); + + b.Navigation("ChildImageVersion"); + + b.Navigation("ScanRun"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ImageVersion", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.RegistryRepository", "RegistryRepository") + .WithMany("ImageVersions") + .HasForeignKey("RegistryRepositoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RegistryRepository"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ObservedImage", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.ImageVersion", "CurrentImageVersion") + .WithMany("ObservedImages") + .HasForeignKey("CurrentImageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurrentImageVersion"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.PortainerEndpoint", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.DockerInstance", "DockerInstance") + .WithOne("PortainerEndpoint") + .HasForeignKey("DockerUpdateGuard.Data.Entities.PortainerEndpoint", "DockerInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DockerInstance"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.RuntimeContainerResourceSample", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.DockerInstance", "DockerInstance") + .WithMany() + .HasForeignKey("DockerInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DockerInstance"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.RuntimeContainerTagSelection", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.DockerInstance", "DockerInstance") + .WithMany() + .HasForeignKey("DockerInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DockerUpdateGuard.Data.Entities.RegistryRepository", "RegistryRepository") + .WithMany() + .HasForeignKey("RegistryRepositoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DockerInstance"); + + b.Navigation("RegistryRepository"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ScanRun", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.DockerInstance", "DockerInstance") + .WithMany("ScanRuns") + .HasForeignKey("DockerInstanceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DockerUpdateGuard.Data.Entities.ObservedImage", "ObservedImage") + .WithMany("ScanRuns") + .HasForeignKey("ObservedImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("DockerInstance"); + + b.Navigation("ObservedImage"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.TagCandidate", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.UpdateFinding", "UpdateFinding") + .WithMany("TagCandidates") + .HasForeignKey("UpdateFindingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UpdateFinding"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.UpdateFinding", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.ContainerSnapshot", "ContainerSnapshot") + .WithMany("UpdateFindings") + .HasForeignKey("ContainerSnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DockerUpdateGuard.Data.Entities.ObservedImage", "ObservedImage") + .WithMany("UpdateFindings") + .HasForeignKey("ObservedImageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DockerUpdateGuard.Data.Entities.ImageVersion", "RecommendedImageVersion") + .WithMany("RecommendedByUpdateFindings") + .HasForeignKey("RecommendedImageVersionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("DockerUpdateGuard.Data.Entities.ScanRun", "ScanRun") + .WithMany("UpdateFindings") + .HasForeignKey("ScanRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DockerUpdateGuard.Data.Entities.ImageVersion", "SubjectImageVersion") + .WithMany("SubjectUpdateFindings") + .HasForeignKey("SubjectImageVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ContainerSnapshot"); + + b.Navigation("ObservedImage"); + + b.Navigation("RecommendedImageVersion"); + + b.Navigation("ScanRun"); + + b.Navigation("SubjectImageVersion"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.VulnerabilityFinding", b => + { + b.HasOne("DockerUpdateGuard.Data.Entities.ImageVersion", "ImageVersion") + .WithMany("VulnerabilityFindings") + .HasForeignKey("ImageVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DockerUpdateGuard.Data.Entities.ScanRun", null) + .WithMany() + .HasForeignKey("ResolvedByScanRunId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("DockerUpdateGuard.Data.Entities.ScanRun", "ScanRun") + .WithMany("VulnerabilityFindings") + .HasForeignKey("ScanRunId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("ImageVersion"); + + b.Navigation("ScanRun"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ContainerSnapshot", b => + { + b.Navigation("ContainerActionRuns"); + + b.Navigation("UpdateFindings"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.DockerInstance", b => + { + b.Navigation("ContainerActionRuns"); + + b.Navigation("ContainerSnapshots"); + + b.Navigation("PortainerEndpoint"); + + b.Navigation("ScanRuns"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ImageVersion", b => + { + b.Navigation("BaseRelationships"); + + b.Navigation("ChildRelationships"); + + b.Navigation("ContainerSnapshots"); + + b.Navigation("ObservedImages"); + + b.Navigation("RecommendedByUpdateFindings"); + + b.Navigation("SubjectUpdateFindings"); + + b.Navigation("VulnerabilityFindings"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ObservedImage", b => + { + b.Navigation("ScanRuns"); + + b.Navigation("UpdateFindings"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.PortainerEndpoint", b => + { + b.Navigation("ContainerActionRuns"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.RegistryRepository", b => + { + b.Navigation("ImageVersions"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.ScanRun", b => + { + b.Navigation("ContainerSnapshots"); + + b.Navigation("ImageRelationships"); + + b.Navigation("UpdateFindings"); + + b.Navigation("VulnerabilityFindings"); + }); + + modelBuilder.Entity("DockerUpdateGuard.Data.Entities.UpdateFinding", b => + { + b.Navigation("TagCandidates"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DockerUpdateGuard.Data/Migrations/Update7.cs b/src/DockerUpdateGuard.Data/Migrations/Update7.cs new file mode 100644 index 0000000..3654ac2 --- /dev/null +++ b/src/DockerUpdateGuard.Data/Migrations/Update7.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DockerUpdateGuard.Data.Migrations; + +/// +public partial class Update7 : Migration +{ + #region Migration + + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn(name: "FixStatus", + table: "VulnerabilityFindings", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn(name: "PackageClass", + table: "VulnerabilityFindings", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn(name: "PackageType", + table: "VulnerabilityFindings", + type: "character varying(50)", + maxLength: 50, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "FixStatus", + table: "VulnerabilityFindings"); + + migrationBuilder.DropColumn(name: "PackageClass", + table: "VulnerabilityFindings"); + + migrationBuilder.DropColumn(name: "PackageType", + table: "VulnerabilityFindings"); + } + + #endregion // Migration +} \ No newline at end of file diff --git a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs index 835c8f3..56ae52b 100644 --- a/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs +++ b/src/DockerUpdateGuard/Images/VulnerabilityEnrichmentService.cs @@ -187,6 +187,9 @@ private static void UpdateFindingFromAdvisory(VulnerabilityFinding finding, Vuln finding.Source = source; finding.InstalledVersion = advisory.InstalledVersion; finding.FixedVersion = advisory.FixedVersion; + finding.FixStatus = advisory.FixStatus; + finding.PackageClass = advisory.PackageClass; + finding.PackageType = advisory.PackageType; finding.CvssScore = advisory.CvssScore; finding.Summary = advisory.Summary; finding.ReferenceUrl = advisory.ReferenceUrl; diff --git a/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyResult.cs b/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyResult.cs index d182de9..7ef2b86 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyResult.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyResult.cs @@ -9,6 +9,24 @@ internal sealed record TrivyResult { #region Properties + /// + /// Scan target the result block belongs to + /// + [JsonPropertyName("Target")] + public string? Target { get; init; } + + /// + /// Package class of the result block (e.g. os-pkgs) + /// + [JsonPropertyName("Class")] + public string? Class { get; init; } + + /// + /// Package type of the result block (e.g. alpine) + /// + [JsonPropertyName("Type")] + public string? Type { get; init; } + /// /// Vulnerabilities /// diff --git a/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyVulnerability.cs b/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyVulnerability.cs index 99ef938..68a3f0d 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyVulnerability.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyVulnerability.cs @@ -33,6 +33,12 @@ internal sealed record TrivyVulnerability [JsonPropertyName("FixedVersion")] public string? FixedVersion { get; init; } + /// + /// Upstream fix status (e.g. fixed, affected, will_not_fix) + /// + [JsonPropertyName("Status")] + public string? Status { get; init; } + /// /// Vulnerability severity /// diff --git a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs index 9174df1..7d03bfc 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs @@ -82,22 +82,58 @@ private static List MapAdvisories(TrivyReport? report return []; } - return report.Results.SelectMany(trivyResult => trivyResult.Vulnerabilities ?? []) - .Select(item => new VulnerabilityAdvisoryData - { - AdvisoryId = item.VulnerabilityId ?? string.Empty, - Title = item.Title ?? item.VulnerabilityId ?? string.Empty, - Severity = MapSeverity(item.Severity), - AffectedPackage = item.PkgName, - InstalledVersion = item.InstalledVersion, - FixedVersion = item.FixedVersion, - CvssScore = ResolveCvssScore(item.Cvss), - Summary = item.Description, - ReferenceUrl = item.PrimaryUrl ?? item.References?.FirstOrDefault(), - }) + // Class and Type live on the result block, so the vulnerabilities are projected together with their owning block + return report.Results.SelectMany(trivyResult => (trivyResult.Vulnerabilities ?? []).Select(item => new + { + Result = trivyResult, + Vulnerability = item, + })) + .Select(entry => new VulnerabilityAdvisoryData + { + AdvisoryId = entry.Vulnerability.VulnerabilityId ?? string.Empty, + Title = entry.Vulnerability.Title ?? entry.Vulnerability.VulnerabilityId ?? string.Empty, + Severity = MapSeverity(entry.Vulnerability.Severity), + AffectedPackage = entry.Vulnerability.PkgName, + InstalledVersion = entry.Vulnerability.InstalledVersion, + FixedVersion = entry.Vulnerability.FixedVersion, + FixStatus = MapFixStatus(entry.Vulnerability.Status), + PackageClass = NormalizeBlockValue(entry.Result.Class), + PackageType = NormalizeBlockValue(entry.Result.Type), + CvssScore = ResolveCvssScore(entry.Vulnerability.Cvss), + Summary = entry.Vulnerability.Description, + ReferenceUrl = entry.Vulnerability.PrimaryUrl ?? entry.Vulnerability.References?.FirstOrDefault(), + }) .ToList(); } + /// + /// Normalize a block-level value so blank values are stored as null + /// + /// Block-level value from Trivy + /// Trimmed value or null when no value was reported + private static string? NormalizeBlockValue(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + /// + /// Map a Trivy status string to the fix status enum + /// + /// Status string from Trivy (e.g. will_not_fix) + /// Fix status enum value + private static VulnerabilityFixStatus MapFixStatus(string? status) + { + return status?.Trim().ToUpperInvariant() switch + { + "FIXED" => VulnerabilityFixStatus.Fixed, + "AFFECTED" => VulnerabilityFixStatus.Affected, + "WILL_NOT_FIX" => VulnerabilityFixStatus.WillNotFix, + "FIX_DEFERRED" => VulnerabilityFixStatus.FixDeferred, + "END_OF_LIFE" => VulnerabilityFixStatus.EndOfLife, + _ => VulnerabilityFixStatus.NotSet, + }; + } + /// /// Resolve a single CVSS score from the per-vendor metrics, preferring NVD and CVSS v3 /// diff --git a/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityAdvisoryData.cs b/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityAdvisoryData.cs index a5c89e8..454b034 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityAdvisoryData.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityAdvisoryData.cs @@ -39,6 +39,21 @@ public class VulnerabilityAdvisoryData /// public string? FixedVersion { get; set; } + /// + /// Fix status reported by the provider + /// + public VulnerabilityFixStatus FixStatus { get; set; } + + /// + /// Optional package class the finding belongs to (e.g. os-pkgs) + /// + public string? PackageClass { get; set; } + + /// + /// Optional package type the finding belongs to (e.g. alpine) + /// + public string? PackageType { get; set; } + /// /// Optional CVSS score /// diff --git a/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs b/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs index 8b9cf2c..aba8069 100644 --- a/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs @@ -425,6 +425,181 @@ public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncNormalizesSev "Trivy scans must map unknown severities to NotSet"); } + /// + /// Verify each vulnerability is attributed to the class and type of its own result block + /// + /// Task + [TestMethod] + public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncAttributesResultBlockClassPerVulnerabilityAsync() + { + var processRunner = Substitute.For(); + + processRunner.RunAsync(Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ProcessExecutionResult + { + ExitCode = 0, + StandardOutput = """ + { + "SchemaVersion": 2, + "Results": [ + { + "Target": "ghcr.io/acme/api:1.0.0 (alpine 3.20.2)", + "Class": "os-pkgs", + "Type": "alpine", + "Vulnerabilities": [ + { "VulnerabilityID": "CVE-2026-0001", "PkgName": "openssl", "Severity": "HIGH", "Status": "will_not_fix" }, + { "VulnerabilityID": "CVE-2026-0002", "PkgName": "busybox", "Severity": "LOW", "Status": "fixed" } + ] + }, + { + "Target": "app/bin/api", + "Class": "lang-pkgs", + "Type": "gobinary", + "Vulnerabilities": [ + { "VulnerabilityID": "CVE-2026-0003", "PkgName": "golang.org/x/net", "Severity": "MEDIUM", "Status": "affected" } + ] + }, + { + "Target": "app/empty", + "Class": "lang-pkgs", + "Type": "jar" + } + ] + } + """, + }); + + var provider = CreateProvider(processRunner, "http://trivy.local:4954"); + + var result = await provider.GetVulnerabilitiesAsync(new ImageReference + { + Registry = "ghcr.io", + Repository = "acme/api", + Tag = "1.0.0", + }, + CancellationToken.None) + .ConfigureAwait(false); + + Assert.AreEqual(ExternalOperationStatus.Succeeded, + result.Status, + "Trivy scans must succeed for reports with multiple result blocks"); + Assert.IsNotNull(result.Data, "Trivy scans must expose advisory data"); + Assert.HasCount(3, + result.Data, + "Trivy scans must map the vulnerabilities of every result block and skip empty blocks"); + Assert.AreEqual("os-pkgs", + result.Data[0].PackageClass, + "Vulnerabilities of the os-pkgs block must carry that block's class"); + Assert.AreEqual("alpine", + result.Data[0].PackageType, + "Vulnerabilities of the os-pkgs block must carry that block's type"); + Assert.AreEqual("os-pkgs", + result.Data[1].PackageClass, + "Every vulnerability of a result block must carry the class of that block"); + Assert.AreEqual("lang-pkgs", + result.Data[2].PackageClass, + "Vulnerabilities of the lang-pkgs block must carry that block's class"); + Assert.AreEqual("gobinary", + result.Data[2].PackageType, + "Vulnerabilities of the lang-pkgs block must carry that block's type"); + Assert.AreEqual(VulnerabilityFixStatus.WillNotFix, + result.Data[0].FixStatus, + "Trivy scans must map the reported fix status of each vulnerability"); + Assert.AreEqual(VulnerabilityFixStatus.Fixed, + result.Data[1].FixStatus, + "Trivy scans must map the reported fix status of each vulnerability"); + Assert.AreEqual(VulnerabilityFixStatus.Affected, + result.Data[2].FixStatus, + "Trivy scans must map the reported fix status of each vulnerability"); + } + + /// + /// Verify Trivy fix statuses are normalized case-insensitively and unknown values map to NotSet + /// + /// Task + [TestMethod] + public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncNormalizesFixStatusesAsync() + { + var processRunner = Substitute.For(); + + processRunner.RunAsync(Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ProcessExecutionResult + { + ExitCode = 0, + StandardOutput = """ + { + "SchemaVersion": 2, + "Results": [ + { + "Target": "library/nginx:latest", + "Vulnerabilities": [ + { "VulnerabilityID": "CVE-2026-0001", "Status": "fixed" }, + { "VulnerabilityID": "CVE-2026-0002", "Status": "Affected" }, + { "VulnerabilityID": "CVE-2026-0003", "Status": "WILL_NOT_FIX" }, + { "VulnerabilityID": "CVE-2026-0004", "Status": "fix_deferred" }, + { "VulnerabilityID": "CVE-2026-0005", "Status": "end_of_life" }, + { "VulnerabilityID": "CVE-2026-0006", "Status": "unknown" }, + { "VulnerabilityID": "CVE-2026-0007", "Status": "bogus" }, + { "VulnerabilityID": "CVE-2026-0008" } + ] + } + ] + } + """, + }); + + var provider = CreateProvider(processRunner, "http://trivy.local:4954"); + + var result = await provider.GetVulnerabilitiesAsync(new ImageReference + { + Registry = "docker.io", + Repository = "library/nginx", + Tag = "latest", + }, + CancellationToken.None) + .ConfigureAwait(false); + + Assert.AreEqual(ExternalOperationStatus.Succeeded, + result.Status, + "Trivy scans must succeed even when a fix status is unknown"); + Assert.IsNotNull(result.Data, "Trivy scans must expose advisory data"); + Assert.HasCount(8, result.Data, "Trivy scans must map all returned vulnerabilities"); + Assert.AreEqual(VulnerabilityFixStatus.Fixed, + result.Data[0].FixStatus, + "Trivy scans must map 'fixed' to Fixed"); + Assert.AreEqual(VulnerabilityFixStatus.Affected, + result.Data[1].FixStatus, + "Trivy scans must map mixed-case 'Affected' to Affected"); + Assert.AreEqual(VulnerabilityFixStatus.WillNotFix, + result.Data[2].FixStatus, + "Trivy scans must map upper-case 'WILL_NOT_FIX' to WillNotFix"); + Assert.AreEqual(VulnerabilityFixStatus.FixDeferred, + result.Data[3].FixStatus, + "Trivy scans must map 'fix_deferred' to FixDeferred"); + Assert.AreEqual(VulnerabilityFixStatus.EndOfLife, + result.Data[4].FixStatus, + "Trivy scans must map 'end_of_life' to EndOfLife"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + result.Data[5].FixStatus, + "Trivy scans must map the provider's 'unknown' status to NotSet"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + result.Data[6].FixStatus, + "Trivy scans must map unrecognized fix statuses to NotSet"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + result.Data[7].FixStatus, + "Trivy scans must map a missing fix status to NotSet"); + Assert.IsNull(result.Data[0].PackageClass, + "Result blocks without a class must leave the package class unset"); + Assert.IsNull(result.Data[0].PackageType, + "Result blocks without a type must leave the package type unset"); + } + /// /// Verify the CVSS score is resolved from the fallback vendors and versions when no NVD v3 score exists /// diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs index a682abb..1cbef43 100644 --- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs @@ -412,6 +412,144 @@ await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) } } + /// + /// Verify the reported fix status and package classification are persisted and refreshed on a re-scan + /// + /// Task + [TestMethod] + public async Task VulnerabilityEnrichmentServiceRefreshAsyncPersistsReportedFixStatusAndPackageClassAsync() + { + using (var database = new SqliteTestDatabase()) + { + var dbContext = database.CreateDbContext(); + + await using (dbContext.ConfigureAwait(false)) + { + var imageCatalogRepository = new ImageCatalogRepository(dbContext); + var currentImageVersionTask = imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io", + "company/app", + "1.0.0", + "sha256:app", + cancellationToken: CancellationToken.None); + var currentImageVersion = await currentImageVersionTask.ConfigureAwait(false); + var observedImage = new ObservedImage + { + Name = "Company App", + CurrentImageVersionId = currentImageVersion.Id, + }; + var vulnerabilityProvider = Substitute.For(); + var vulnerabilityProviderResolver = Substitute.For(); + + vulnerabilityProviderResolver.Resolve().Returns(vulnerabilityProvider); + + var optionsMonitor = new TestOptionsMonitor(new DockerUpdateGuardOptions + { + Vulnerabilities = new VulnerabilityOptions + { + Enabled = true, + Provider = VulnerabilityProviderKind.Trivy, + }, + }); + + dbContext.ObservedImages.Add(observedImage); + await dbContext.SaveChangesAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + vulnerabilityProvider.GetVulnerabilitiesAsync(Arg.Any(), Arg.Any()) + .Returns(ExternalOperationResult>.Succeeded([ + new VulnerabilityAdvisoryData + { + AdvisoryId = "CVE-2026-1000", + Title = "Unfixable advisory", + Severity = VulnerabilitySeverity.Medium, + AffectedPackage = "openssl", + FixStatus = VulnerabilityFixStatus.WillNotFix, + PackageClass = "os-pkgs", + PackageType = "debian", + }, + new VulnerabilityAdvisoryData + { + AdvisoryId = "CVE-2026-2000", + Title = "Advisory without status", + Severity = VulnerabilitySeverity.Low, + AffectedPackage = "zlib", + }, + ]), + ExternalOperationResult>.Succeeded([ + new VulnerabilityAdvisoryData + { + AdvisoryId = "CVE-2026-1000", + Title = "Unfixable advisory", + Severity = VulnerabilitySeverity.Medium, + AffectedPackage = "openssl", + FixedVersion = "3.3.2-r0", + FixStatus = VulnerabilityFixStatus.Fixed, + PackageClass = "os-pkgs", + PackageType = "debian", + }, + new VulnerabilityAdvisoryData + { + AdvisoryId = "CVE-2026-2000", + Title = "Advisory without status", + Severity = VulnerabilitySeverity.Low, + AffectedPackage = "zlib", + }, + ])); + + var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(), + dbContext, + new ImageReferenceParser(), + new LiveImageInventoryQueryService(dbContext), + new TestLogger(), + optionsMonitor, + vulnerabilityProviderResolver); + + await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) + .ConfigureAwait(false); + + var firstFinding = await dbContext.VulnerabilityFindings.SingleAsync(entity => entity.AdvisoryId == "CVE-2026-1000", + TestContext.CancellationToken) + .ConfigureAwait(false); + var firstFindingId = firstFinding.Id; + + Assert.AreEqual(VulnerabilityFixStatus.WillNotFix, + firstFinding.FixStatus, + "The reported fix status must be persisted with the finding"); + Assert.AreEqual("os-pkgs", + firstFinding.PackageClass, + "The reported package class must be persisted with the finding"); + Assert.AreEqual("debian", + firstFinding.PackageType, + "The reported package type must be persisted with the finding"); + + await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) + .ConfigureAwait(false); + + var findings = await dbContext.VulnerabilityFindings.ToListAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + var updatedFinding = findings.Single(entity => entity.AdvisoryId == "CVE-2026-1000"); + var unclassifiedFinding = findings.Single(entity => entity.AdvisoryId == "CVE-2026-2000"); + + Assert.HasCount(2, + findings, + "Persisting the fix status must not change the finding identity on a re-scan"); + Assert.AreEqual(firstFindingId, + updatedFinding.Id, + "A re-scan must update the existing finding row instead of creating a new one"); + Assert.AreEqual(VulnerabilityFixStatus.Fixed, + updatedFinding.FixStatus, + "A re-scan must take over the latest reported fix status"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + unclassifiedFinding.FixStatus, + "Providers that do not report a fix status must leave the finding at NotSet"); + Assert.IsNull(unclassifiedFinding.PackageClass, + "Providers that do not report a package class must leave the finding value null"); + Assert.IsNull(unclassifiedFinding.PackageType, + "Providers that do not report a package type must leave the finding value null"); + } + } + } + /// /// Verify the final scan-run state is persisted and visible to other database contexts /// From 3da2c2ec75e9c345cfbdd0aa71056a7a51af4da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:54:10 +0000 Subject: [PATCH 2/2] Cover the fix status migration and provider edge cases with tests The quality gate reported 72.9 percent coverage on new code because the Update7 migration contributed 23 uncovered lines. Update7MigrationTests now asserts the added columns, their defaults and the rollback following the Update6 pattern, and MappingTests checks the new mapping and round-trip plus the unchanged finding identity index. Additional tests cover blank and padded Trivy block values, Docker Scout advisories leaving the fields unset, and a reactivated finding taking over the reported values. Refs #207 --- .../MappingTests.cs | 46 ++++++ .../Update7MigrationTests.cs | 131 ++++++++++++++++++ .../DockerScoutVulnerabilityProviderTests.cs | 47 +++++++ .../TrivyVulnerabilityProviderTests.cs | 72 ++++++++++ .../VulnerabilityEnrichmentServiceTests.cs | 105 ++++++++++++++ 5 files changed, 401 insertions(+) create mode 100644 src/Tests/DockerUpdateGuard.Data.Tests/Update7MigrationTests.cs diff --git a/src/Tests/DockerUpdateGuard.Data.Tests/MappingTests.cs b/src/Tests/DockerUpdateGuard.Data.Tests/MappingTests.cs index a1e8600..efd69c3 100644 --- a/src/Tests/DockerUpdateGuard.Data.Tests/MappingTests.cs +++ b/src/Tests/DockerUpdateGuard.Data.Tests/MappingTests.cs @@ -83,6 +83,36 @@ public void DockerUpdateGuardDbContextModelHasExpectedMappings() Assert.IsNotNull(imageVersionEntity.FindProperty(nameof(ImageVersion.VulnerabilityAssessmentScanRunId)), "Image version must map the scan run of the latest vulnerability assessment"); + + var fixStatusProperty = vulnerabilityFindingEntity.FindProperty(nameof(VulnerabilityFinding.FixStatus)); + var packageClassProperty = vulnerabilityFindingEntity.FindProperty(nameof(VulnerabilityFinding.PackageClass)); + var packageTypeProperty = vulnerabilityFindingEntity.FindProperty(nameof(VulnerabilityFinding.PackageType)); + + Assert.IsNotNull(fixStatusProperty, "Vulnerability finding must map the reported fix status"); + Assert.IsFalse(fixStatusProperty.IsNullable, "The fix status must be non-nullable and default to NotSet"); + Assert.IsNotNull(packageClassProperty, "Vulnerability finding must map the reported package class"); + Assert.IsTrue(packageClassProperty.IsNullable, "The package class must stay optional for providers that do not report one"); + Assert.AreEqual(50, + packageClassProperty.GetMaxLength(), + "The package class must use the configured maximum length"); + Assert.IsNotNull(packageTypeProperty, "Vulnerability finding must map the reported package type"); + Assert.IsTrue(packageTypeProperty.IsNullable, "The package type must stay optional for providers that do not report one"); + Assert.AreEqual(50, + packageTypeProperty.GetMaxLength(), + "The package type must use the configured maximum length"); + + var findingIdentityIndex = vulnerabilityFindingEntity.GetIndexes() + .SingleOrDefault(index => index.IsUnique); + + Assert.IsNotNull(findingIdentityIndex, "Vulnerability findings must keep a unique identity index"); + Assert.AreSequenceEqual([ + nameof(VulnerabilityFinding.ImageVersionId), + nameof(VulnerabilityFinding.AdvisoryId), + nameof(VulnerabilityFinding.AffectedPackage), + ], + findingIdentityIndex.Properties.Select(property => property.Name) + .ToList(), + "The finding identity must stay the image version, advisory and affected package"); } } } @@ -204,7 +234,10 @@ public async Task DockerUpdateGuardDbContextPersistenceRoundTripStoresRepresenta AdvisoryId = "CVE-2026-0001", DetectedAtUtc = DateTimeOffset.UtcNow, ImageVersion = baseImageVersion, + FixStatus = VulnerabilityFixStatus.WillNotFix, IsActive = true, + PackageClass = "os-pkgs", + PackageType = "debian", Severity = VulnerabilitySeverity.High, Summary = "Representative advisory", Title = "glibc issue" @@ -267,6 +300,19 @@ await dbContext.ContainerActionRuns.CountAsync(TestContext.CancellationToken).Co Assert.HasCount(1, persistedUpdateFinding.TagCandidates, "The update finding must keep its tag candidates"); + + var persistedVulnerabilityFinding = await dbContext.VulnerabilityFindings.SingleAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + Assert.AreEqual(VulnerabilityFixStatus.WillNotFix, + persistedVulnerabilityFinding.FixStatus, + "The reported fix status must survive the persistence round-trip"); + Assert.AreEqual("os-pkgs", + persistedVulnerabilityFinding.PackageClass, + "The reported package class must survive the persistence round-trip"); + Assert.AreEqual("debian", + persistedVulnerabilityFinding.PackageType, + "The reported package type must survive the persistence round-trip"); } } } diff --git a/src/Tests/DockerUpdateGuard.Data.Tests/Update7MigrationTests.cs b/src/Tests/DockerUpdateGuard.Data.Tests/Update7MigrationTests.cs new file mode 100644 index 0000000..8ade699 --- /dev/null +++ b/src/Tests/DockerUpdateGuard.Data.Tests/Update7MigrationTests.cs @@ -0,0 +1,131 @@ +using DockerUpdateGuard.Data.Migrations; + +using Microsoft.EntityFrameworkCore.Migrations.Operations; + +namespace DockerUpdateGuard.Data.Tests; + +/// +/// Tests for +/// +[TestClass] +public class Update7MigrationTests +{ + #region Const fields + + /// + /// Provider the migration operations are built for + /// + private const string ActiveProvider = "Npgsql.EntityFrameworkCore.PostgreSQL"; + + #endregion // Const fields + + #region Static methods + + /// + /// Create the migration under test with the active provider applied + /// + /// Migration under test + private static Update7 CreateMigration() + { + return new Update7 + { + ActiveProvider = ActiveProvider, + }; + } + + #endregion // Static methods + + #region Methods + + /// + /// Verify the migration adds the fix status column with a NotSet default so existing findings stay valid + /// + [TestMethod] + public void Update7UpAddsFixStatusColumnDefaultingToNotSet() + { + var operations = CreateMigration().UpOperations; + var fixStatusColumn = operations.OfType() + .SingleOrDefault(operation => operation.Table == "VulnerabilityFindings" + && operation.Name == "FixStatus"); + + Assert.IsNotNull(fixStatusColumn, "The migration must add the fix status column to the vulnerability findings"); + Assert.AreEqual("integer", + fixStatusColumn.ColumnType, + "The fix status column must be stored as an integer enum value"); + Assert.IsFalse(fixStatusColumn.IsNullable, "The fix status column must be non-nullable"); + Assert.AreEqual(0, + fixStatusColumn.DefaultValue, + "Existing findings must default to NotSet so they are treated as 'no information'"); + } + + /// + /// Verify the migration adds the nullable package classification columns + /// + [TestMethod] + public void Update7UpAddsNullablePackageClassificationColumns() + { + var operations = CreateMigration().UpOperations; + var addedColumns = operations.OfType() + .ToList(); + var packageClassColumn = addedColumns.SingleOrDefault(operation => operation.Table == "VulnerabilityFindings" + && operation.Name == "PackageClass"); + var packageTypeColumn = addedColumns.SingleOrDefault(operation => operation.Table == "VulnerabilityFindings" + && operation.Name == "PackageType"); + + Assert.IsNotNull(packageClassColumn, "The migration must add the package class column to the vulnerability findings"); + Assert.IsTrue(packageClassColumn.IsNullable, "The package class column must be nullable for providers that do not report one"); + Assert.AreEqual(50, + packageClassColumn.MaxLength, + "The package class column must use the configured maximum length"); + Assert.IsNotNull(packageTypeColumn, "The migration must add the package type column to the vulnerability findings"); + Assert.IsTrue(packageTypeColumn.IsNullable, "The package type column must be nullable for providers that do not report one"); + Assert.AreEqual(50, + packageTypeColumn.MaxLength, + "The package type column must use the configured maximum length"); + } + + /// + /// Verify the migration only touches the vulnerability findings and leaves the finding identity index alone + /// + [TestMethod] + public void Update7UpLeavesFindingIdentityUnchanged() + { + var operations = CreateMigration().UpOperations; + + Assert.HasCount(3, + operations, + "The migration must add exactly the three new columns"); + Assert.IsEmpty(operations.OfType(), + "The migration must not change any index so the finding identity stays untouched"); + Assert.IsEmpty(operations.OfType(), + "The migration must not drop any index so the finding identity stays untouched"); + Assert.IsTrue(operations.OfType() + .All(operation => operation.Table == "VulnerabilityFindings"), + "The migration must only extend the vulnerability findings table"); + } + + /// + /// Verify the migration reverts every schema change it applies + /// + [TestMethod] + public void Update7DownRemovesTheAddedColumns() + { + var operations = CreateMigration().DownOperations; + var droppedColumns = operations.OfType() + .Select(operation => operation.Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + Assert.HasCount(3, + operations, + "The rollback must drop exactly the three columns added by the migration"); + Assert.AreSequenceEqual(["FixStatus", "PackageClass", "PackageType"], + droppedColumns, + "The rollback must drop every column added by the migration"); + Assert.IsTrue(operations.OfType() + .All(operation => operation.Table == "VulnerabilityFindings"), + "The rollback must only touch the vulnerability findings table"); + } + + #endregion // Methods +} \ No newline at end of file diff --git a/src/Tests/DockerUpdateGuard.Tests/DockerScoutVulnerabilityProviderTests.cs b/src/Tests/DockerUpdateGuard.Tests/DockerScoutVulnerabilityProviderTests.cs index 7ebf9dc..107d209 100644 --- a/src/Tests/DockerUpdateGuard.Tests/DockerScoutVulnerabilityProviderTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/DockerScoutVulnerabilityProviderTests.cs @@ -381,6 +381,53 @@ public async Task DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncReturns } } + /// + /// Verify Docker Scout advisories leave the Trivy-specific fix status and package classification unset + /// + /// Task + [TestMethod] + public async Task DockerScoutVulnerabilityProviderGetVulnerabilitiesAsyncLeavesFixStatusAndPackageClassUnsetAsync() + { + var handler = new SequenceHttpMessageHandler(); + var httpClient = new HttpClient(handler); + + try + { + handler.AddJsonResponse(HubLoginUrl, """{"token":"jwt-token"}"""); + handler.AddJsonResponse(ScoutVulnerabilitiesUrl, + """ + { + "vulnerabilities": [ + { "id": "CVE-2026-0001", "severity": "high", "description": "Example advisory" } + ] + } + """); + + var logger = new TestLogger(); + var provider = CreateProvider(httpClient, logger); + + var result = await provider.GetVulnerabilitiesAsync(CreateImageReference(), CancellationToken.None).ConfigureAwait(false); + + Assert.AreEqual(ExternalOperationStatus.Succeeded, + result.Status, + "Docker Scout scans must succeed when the server returns advisory results"); + Assert.IsNotNull(result.Data, "Docker Scout scans must expose advisory data"); + Assert.HasCount(1, result.Data, "Docker Scout scans must map all returned vulnerabilities"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + result.Data[0].FixStatus, + "Docker Scout does not report a fix status, so the advisory must stay at NotSet"); + Assert.IsNull(result.Data[0].PackageClass, + "Docker Scout does not report a package class, so the advisory must leave it null"); + Assert.IsNull(result.Data[0].PackageType, + "Docker Scout does not report a package type, so the advisory must leave it null"); + } + finally + { + httpClient.Dispose(); + handler.Dispose(); + } + } + /// /// Verify Docker Scout rejects a null image reference instead of dereferencing it /// diff --git a/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs b/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs index aba8069..e2b8ff0 100644 --- a/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs @@ -600,6 +600,78 @@ public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncNormalizesFix "Result blocks without a type must leave the package type unset"); } + /// + /// Verify blank block-level values are stored as null and padded values are trimmed + /// + /// Task + [TestMethod] + public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncNormalizesBlankBlockValuesAsync() + { + var processRunner = Substitute.For(); + + processRunner.RunAsync(Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ProcessExecutionResult + { + ExitCode = 0, + StandardOutput = """ + { + "SchemaVersion": 2, + "Results": [ + { + "Target": "library/nginx:latest", + "Class": "", + "Type": " ", + "Vulnerabilities": [ + { "VulnerabilityID": "CVE-2026-0001", "Status": " will_not_fix " } + ] + }, + { + "Target": "library/nginx:latest", + "Class": " os-pkgs ", + "Type": " alpine ", + "Vulnerabilities": [ + { "VulnerabilityID": "CVE-2026-0002", "Status": "affected" } + ] + } + ] + } + """, + }); + + var provider = CreateProvider(processRunner, "http://trivy.local:4954"); + + var result = await provider.GetVulnerabilitiesAsync(new ImageReference + { + Registry = "docker.io", + Repository = "library/nginx", + Tag = "latest", + }, + CancellationToken.None) + .ConfigureAwait(false); + + Assert.AreEqual(ExternalOperationStatus.Succeeded, + result.Status, + "Trivy scans must succeed when block-level values are blank"); + Assert.IsNotNull(result.Data, "Trivy scans must expose advisory data"); + Assert.HasCount(2, result.Data, "Trivy scans must map all returned vulnerabilities"); + Assert.IsNull(result.Data[0].PackageClass, + "An empty package class must be stored as null instead of an empty string"); + Assert.IsNull(result.Data[0].PackageType, + "A whitespace-only package type must be stored as null instead of blank text"); + Assert.AreEqual(VulnerabilityFixStatus.WillNotFix, + result.Data[0].FixStatus, + "A padded fix status must still be recognized"); + Assert.AreEqual("os-pkgs", + result.Data[1].PackageClass, + "A padded package class must be trimmed"); + Assert.AreEqual("alpine", + result.Data[1].PackageType, + "A padded package type must be trimmed"); + } + /// /// Verify the CVSS score is resolved from the fallback vendors and versions when no NVD v3 score exists /// diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs index 1cbef43..75ef1b7 100644 --- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs @@ -921,6 +921,111 @@ await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) } } + /// + /// Verify a legacy finding stored without a fix status takes over the reported values when it is reactivated + /// + /// Task + [TestMethod] + public async Task VulnerabilityEnrichmentServiceRefreshAsyncReactivatedFindingTakesOverReportedFixStatusAsync() + { + using (var database = new SqliteTestDatabase()) + { + var dbContext = database.CreateDbContext(); + + await using (dbContext.ConfigureAwait(false)) + { + var imageCatalogRepository = new ImageCatalogRepository(dbContext); + var currentImageVersion = await imageCatalogRepository.GetOrCreateImageVersionAsync("docker.io", + "company/app", + "1.0.0", + "sha256:app", + cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + var legacyFinding = new VulnerabilityFinding + { + ImageVersionId = currentImageVersion.Id, + AdvisoryId = "CVE-2026-4000", + Title = "Legacy advisory", + Severity = VulnerabilitySeverity.Low, + Source = VulnerabilitySource.Trivy, + AffectedPackage = "openssl", + IsActive = false, + DetectedAtUtc = DateTimeOffset.UtcNow.AddDays(-3), + ResolvedAtUtc = DateTimeOffset.UtcNow.AddDays(-2), + }; + var vulnerabilityProvider = Substitute.For(); + var vulnerabilityProviderResolver = Substitute.For(); + + vulnerabilityProviderResolver.Resolve().Returns(vulnerabilityProvider); + + var optionsMonitor = new TestOptionsMonitor(new DockerUpdateGuardOptions + { + Vulnerabilities = new VulnerabilityOptions + { + Enabled = true, + Provider = VulnerabilityProviderKind.Trivy, + }, + }); + + dbContext.ObservedImages.Add(new ObservedImage + { + Name = "Company App", + CurrentImageVersionId = currentImageVersion.Id, + }); + dbContext.VulnerabilityFindings.Add(legacyFinding); + await dbContext.SaveChangesAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + legacyFinding.FixStatus, + "Findings stored before the migration must start out without fix information"); + + vulnerabilityProvider.GetVulnerabilitiesAsync(Arg.Any(), Arg.Any()) + .Returns(ExternalOperationResult>.Succeeded([ + new VulnerabilityAdvisoryData + { + AdvisoryId = "CVE-2026-4000", + Title = "Legacy advisory", + Severity = VulnerabilitySeverity.High, + AffectedPackage = "openssl", + FixStatus = VulnerabilityFixStatus.EndOfLife, + PackageClass = "os-pkgs", + PackageType = "alpine", + }, + ])); + + var service = new VulnerabilityEnrichmentService(new ApplicationTelemetry(), + dbContext, + new ImageReferenceParser(), + new LiveImageInventoryQueryService(dbContext), + new TestLogger(), + optionsMonitor, + vulnerabilityProviderResolver); + + await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) + .ConfigureAwait(false); + + var findings = await dbContext.VulnerabilityFindings.ToListAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + var reactivatedFinding = findings.Single(); + + Assert.AreEqual(legacyFinding.Id, + reactivatedFinding.Id, + "A reactivated finding must keep its identity instead of being stored as a second row"); + Assert.IsTrue(reactivatedFinding.IsActive, "The reported advisory must reactivate the resolved finding"); + Assert.AreEqual(VulnerabilityFixStatus.EndOfLife, + reactivatedFinding.FixStatus, + "A reactivated finding must take over the reported fix status"); + Assert.AreEqual("os-pkgs", + reactivatedFinding.PackageClass, + "A reactivated finding must take over the reported package class"); + Assert.AreEqual("alpine", + reactivatedFinding.PackageType, + "A reactivated finding must take over the reported package type"); + } + } + } + /// /// Verify a resolved finding whose advisory is reported again is reactivated instead of stored as a second row ///