diff --git a/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs b/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs index 5639cb5..7903c5d 100644 --- a/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs +++ b/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs @@ -47,9 +47,18 @@ public void Configure(EntityTypeBuilder builder) builder.Property(entity => entity.CvssScore) .HasPrecision(5, 2); + builder.Property(entity => entity.PackageClass) + .HasMaxLength(50); + + builder.Property(entity => entity.PackageType) + .HasMaxLength(50); + builder.Property(entity => entity.Source) .IsRequired(); + builder.Property(entity => entity.FixStatus) + .IsRequired(); + builder.Property(entity => entity.DetectedAtUtc) .IsRequired(); diff --git a/src/DockerUpdateGuard.Data/Entities/VulnerabilityFinding.cs b/src/DockerUpdateGuard.Data/Entities/VulnerabilityFinding.cs index 74c9856..73df5f3 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, when the provider supplies none + /// + public VulnerabilityFixStatus FixStatus { get; set; } + + /// + /// Optional package class the finding was reported for (e.g. os-pkgs, lang-pkgs) + /// + public string? PackageClass { get; set; } + + /// + /// Optional distribution or ecosystem the finding was reported for (e.g. alpine, gobinary) + /// + 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..1ce930d --- /dev/null +++ b/src/DockerUpdateGuard.Data/Entities/VulnerabilityFixStatus.cs @@ -0,0 +1,37 @@ +namespace DockerUpdateGuard.Data.Entities; + +/// +/// Fix status reported by the vulnerability provider for a finding +/// +public enum VulnerabilityFixStatus +{ + /// + /// No fix status set because the provider reported none or reported it as unknown + /// + NotSet = 0, + + /// + /// A fixed package version is available upstream + /// + Fixed = 1, + + /// + /// The package is affected and no fix is available yet + /// + Affected = 2, + + /// + /// Upstream decided not to fix the vulnerability + /// + WillNotFix = 3, + + /// + /// The affected package reached its end of life and receives no fix + /// + EndOfLife = 4, + + /// + /// Upstream deferred the fix to a later point in time + /// + FixDeferred = 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..86f7dd3 --- /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("20260727174335_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..2c3223f 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 + /// + /// Scanned target the result block belongs to + /// + [JsonPropertyName("Target")] + public string? Target { get; init; } + + /// + /// Package class of the result block (e.g. os-pkgs, lang-pkgs) + /// + [JsonPropertyName("Class")] + public string? Class { get; init; } + + /// + /// Distribution or ecosystem of the result block (e.g. alpine, gobinary) + /// + [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..953f145 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyVulnerability.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/Data/TrivyVulnerability.cs @@ -39,6 +39,12 @@ internal sealed record TrivyVulnerability [JsonPropertyName("Severity")] public string? Severity { get; init; } + /// + /// Fix status reported by the vulnerability provider (e.g. fixed, will_not_fix) + /// + [JsonPropertyName("Status")] + public string? Status { get; init; } + /// /// CVSS metrics per vendor /// diff --git a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs index 9174df1..7547d7b 100644 --- a/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs +++ b/src/DockerUpdateGuard/Vulnerabilities/TrivyVulnerabilityProvider.cs @@ -82,22 +82,36 @@ 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(), - }) + // The class and type live on the result block, so each vulnerability is projected together with its own block + return report.Results.SelectMany(trivyResult => trivyResult.Vulnerabilities ?? [], + (trivyResult, 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, + FixStatus = MapFixStatus(item.Status), + PackageClass = NormalizeBlockValue(trivyResult.Class), + PackageType = NormalizeBlockValue(trivyResult.Type), + CvssScore = ResolveCvssScore(item.Cvss), + Summary = item.Description, + ReferenceUrl = item.PrimaryUrl ?? item.References?.FirstOrDefault(), + }) .ToList(); } + /// + /// Normalize a block-level value so blank values are stored as null + /// + /// Block-level value reported by Trivy + /// Trimmed value or null when the block reports none + private static string? NormalizeBlockValue(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + /// /// Resolve a single CVSS score from the per-vendor metrics, preferring NVD and CVSS v3 /// @@ -138,6 +152,24 @@ private static VulnerabilitySeverity MapSeverity(string? severity) }; } + /// + /// Map a Trivy status string to the fix status enum, treating the reported "unknown" status as no information + /// + /// Status string from Trivy (e.g. will_not_fix) + /// Fix status enum value + private static VulnerabilityFixStatus MapFixStatus(string? status) + { + return status?.ToUpperInvariant() switch + { + "FIXED" => VulnerabilityFixStatus.Fixed, + "AFFECTED" => VulnerabilityFixStatus.Affected, + "WILL_NOT_FIX" => VulnerabilityFixStatus.WillNotFix, + "END_OF_LIFE" => VulnerabilityFixStatus.EndOfLife, + "FIX_DEFERRED" => VulnerabilityFixStatus.FixDeferred, + _ => VulnerabilityFixStatus.NotSet, + }; + } + /// /// Resolve the scan target passed to the Trivy CLI /// diff --git a/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityAdvisoryData.cs b/src/DockerUpdateGuard/Vulnerabilities/VulnerabilityAdvisoryData.cs index a5c89e8..6ef2057 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, when the provider supplies none + /// + public VulnerabilityFixStatus FixStatus { get; set; } + + /// + /// Optional package class the advisory was reported for (e.g. os-pkgs, lang-pkgs) + /// + public string? PackageClass { get; set; } + + /// + /// Optional distribution or ecosystem the advisory was reported for (e.g. alpine, gobinary) + /// + 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..0dcfee7 100644 --- a/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/TrivyVulnerabilityProviderTests.cs @@ -537,6 +537,227 @@ await Assert.ThrowsExactlyAsync(() => provider.GetVulnera .ConfigureAwait(false); } + /// + /// Verify each vulnerability is attributed to the class and type of its own result block + /// + /// Task + [TestMethod] + public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncMapsBlockClassPerVulnerabilityAsync() + { + var processRunner = Substitute.For(); + + processRunner.RunAsync(Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ProcessExecutionResult + { + ExitCode = 0, + StandardOutput = """ + { + "SchemaVersion": 2, + "ArtifactName": "ghcr.io/acme/api:1.0.0", + "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" + }, + { + "VulnerabilityID": "CVE-2026-0002", + "PkgName": "zlib", + "Severity": "LOW" + } + ] + }, + { + "Target": "app/bin/api", + "Class": "lang-pkgs", + "Type": "gobinary", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2026-0003", + "PkgName": "golang.org/x/net", + "Severity": "MEDIUM" + } + ] + }, + { + "Target": "app/bin/tool", + "Class": " ", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2026-0004", + "PkgName": "tool", + "Severity": "LOW" + } + ] + } + ] + } + """, + }); + + 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 when the CLI returns multiple result blocks"); + Assert.IsNotNull(result.Data, "Trivy scans must expose advisory data"); + Assert.HasCount(4, + result.Data, + "Trivy scans must map the vulnerabilities of every result block"); + Assert.AreEqual("os-pkgs", + result.Data[0].PackageClass, + "Trivy scans must attribute the operating system package class to its own block"); + Assert.AreEqual("alpine", + result.Data[0].PackageType, + "Trivy scans must attribute the operating system package type to its own block"); + Assert.AreEqual("os-pkgs", + result.Data[1].PackageClass, + "Trivy scans must attribute the block class to every vulnerability inside the block"); + Assert.AreEqual("lang-pkgs", + result.Data[2].PackageClass, + "Trivy scans must attribute the language package class to its own block"); + Assert.AreEqual("gobinary", + result.Data[2].PackageType, + "Trivy scans must attribute the language package type to its own block"); + Assert.IsNull(result.Data[3].PackageClass, + "Trivy scans must leave a blank block class empty"); + Assert.IsNull(result.Data[3].PackageType, + "Trivy scans must leave a missing block type empty"); + } + + /// + /// Verify Trivy fix statuses are mapped to the fix status enum + /// + /// Task + [TestMethod] + public async Task TrivyVulnerabilityProviderGetVulnerabilitiesAsyncMapsFixStatusesAsync() + { + var processRunner = Substitute.For(); + + processRunner.RunAsync(Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any()) + .Returns(new ProcessExecutionResult + { + ExitCode = 0, + StandardOutput = """ + { + "SchemaVersion": 2, + "ArtifactName": "ghcr.io/acme/api:1.0.0", + "Results": [ + { + "Target": "ghcr.io/acme/api:1.0.0 (debian 12.6)", + "Class": "os-pkgs", + "Type": "debian", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2026-0001", + "PkgName": "openssl", + "Status": "fixed" + }, + { + "VulnerabilityID": "CVE-2026-0002", + "PkgName": "zlib", + "Status": "affected" + }, + { + "VulnerabilityID": "CVE-2026-0003", + "PkgName": "libxml2", + "Status": "will_not_fix" + }, + { + "VulnerabilityID": "CVE-2026-0004", + "PkgName": "python3.9", + "Status": "END_OF_LIFE" + }, + { + "VulnerabilityID": "CVE-2026-0005", + "PkgName": "curl", + "Status": "fix_deferred" + }, + { + "VulnerabilityID": "CVE-2026-0006", + "PkgName": "busybox", + "Status": "unknown" + }, + { + "VulnerabilityID": "CVE-2026-0007", + "PkgName": "bash", + "Status": "something_new" + }, + { + "VulnerabilityID": "CVE-2026-0008", + "PkgName": "musl" + } + ] + } + ] + } + """, + }); + + 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 when the CLI reports fix statuses"); + Assert.IsNotNull(result.Data, "Trivy scans must expose advisory data"); + Assert.HasCount(8, + result.Data, + "Trivy scans must map every reported vulnerability"); + Assert.AreEqual(VulnerabilityFixStatus.Fixed, + result.Data[0].FixStatus, + "Trivy scans must map the fixed status"); + Assert.AreEqual(VulnerabilityFixStatus.Affected, + result.Data[1].FixStatus, + "Trivy scans must map the affected status"); + Assert.AreEqual(VulnerabilityFixStatus.WillNotFix, + result.Data[2].FixStatus, + "Trivy scans must map the will_not_fix status"); + Assert.AreEqual(VulnerabilityFixStatus.EndOfLife, + result.Data[3].FixStatus, + "Trivy scans must map the end_of_life status case-insensitively"); + Assert.AreEqual(VulnerabilityFixStatus.FixDeferred, + result.Data[4].FixStatus, + "Trivy scans must map the fix_deferred status"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + result.Data[5].FixStatus, + "Trivy scans must treat the unknown status as no fix status information"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + result.Data[6].FixStatus, + "Trivy scans must fall back to no fix status information for unrecognized status values"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + result.Data[7].FixStatus, + "Trivy scans must fall back to no fix status information when no status is reported"); + } + /// /// Create a configured provider instance /// diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs index a682abb..05307a1 100644 --- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityEnrichmentServiceTests.cs @@ -1497,5 +1497,148 @@ 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 VulnerabilityEnrichmentServiceRefreshAsyncPersistsFixStatusAndPackageClassificationAsync() + { + 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 = "Unclassified advisory", + 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 = "lang-pkgs", + PackageType = "gobinary", + }, + new VulnerabilityAdvisoryData + { + AdvisoryId = "CVE-2026-2000", + Title = "Unclassified advisory", + 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 findingsAfterFirstRefresh = await dbContext.VulnerabilityFindings.OrderBy(entity => entity.AdvisoryId) + .ToListAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + var classifiedFindingId = findingsAfterFirstRefresh[0].Id; + + Assert.AreEqual(VulnerabilityFixStatus.WillNotFix, + findingsAfterFirstRefresh[0].FixStatus, + "The reported fix status must be persisted with the finding"); + Assert.AreEqual("os-pkgs", + findingsAfterFirstRefresh[0].PackageClass, + "The reported package class must be persisted with the finding"); + Assert.AreEqual("debian", + findingsAfterFirstRefresh[0].PackageType, + "The reported package type must be persisted with the finding"); + Assert.AreEqual(VulnerabilityFixStatus.NotSet, + findingsAfterFirstRefresh[1].FixStatus, + "Advisories without a reported fix status must stay unset"); + Assert.IsNull(findingsAfterFirstRefresh[1].PackageClass, + "Advisories without a reported package class must stay unset"); + Assert.IsNull(findingsAfterFirstRefresh[1].PackageType, + "Advisories without a reported package type must stay unset"); + + await service.RefreshAsync(ScanTriggerSource.Manual, CancellationToken.None) + .ConfigureAwait(false); + + var findingsAfterSecondRefresh = await dbContext.VulnerabilityFindings.OrderBy(entity => entity.AdvisoryId) + .ToListAsync(TestContext.CancellationToken) + .ConfigureAwait(false); + + Assert.HasCount(2, + findingsAfterSecondRefresh, + "A re-scan must not create additional rows for findings that only changed their fix status"); + Assert.AreEqual(classifiedFindingId, + findingsAfterSecondRefresh[0].Id, + "A re-scan must keep the finding identity of an already stored advisory"); + Assert.AreEqual(VulnerabilityFixStatus.Fixed, + findingsAfterSecondRefresh[0].FixStatus, + "A re-scan must take over the latest reported fix status"); + Assert.AreEqual("lang-pkgs", + findingsAfterSecondRefresh[0].PackageClass, + "A re-scan must take over the latest reported package class"); + Assert.AreEqual("gobinary", + findingsAfterSecondRefresh[0].PackageType, + "A re-scan must take over the latest reported package type"); + } + } + } + #endregion // Methods } \ No newline at end of file