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.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 8b9cf2c..e2b8ff0 100644 --- a/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs @@ -425,6 +425,253 @@ 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 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 a682abb..75ef1b7 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 /// @@ -783,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 ///