From ff875ad3d09d9a27b486556ace64c65abbf880d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:47:50 +0000 Subject: [PATCH] Capture Trivy fix status and package classification per finding Trivy reports a per-vulnerability Status and a per-result-block Class and Type, all of which were discarded on parse. The Trivy payload records now carry these values, the provider projects each vulnerability together with its own result block so the block-level class and type are attributed correctly, and the values travel through the advisory transport into the new FixStatus, PackageClass and PackageType columns added by migration Update7. Unrecognized and unknown status strings fall back to NotSet, and finding identity stays AdvisoryId plus AffectedPackage so a re-scan updates existing rows instead of duplicating them. Refs #207 --- .../VulnerabilityFindingConfiguration.cs | 9 + .../Entities/VulnerabilityFinding.cs | 15 + .../Entities/VulnerabilityFixStatus.cs | 37 + ...DockerUpdateGuardDbContextModelSnapshot.cs | 11 + .../Migrations/Update7.Designer.cs | 1099 +++++++++++++++++ .../Migrations/Update7.cs | 48 + .../Images/VulnerabilityEnrichmentService.cs | 3 + .../Vulnerabilities/Data/TrivyResult.cs | 18 + .../Data/TrivyVulnerability.cs | 6 + .../TrivyVulnerabilityProvider.cs | 58 +- .../VulnerabilityAdvisoryData.cs | 15 + .../TrivyVulnerabilityProviderTests.cs | 221 ++++ .../VulnerabilityEnrichmentServiceTests.cs | 143 +++ 13 files changed, 1670 insertions(+), 13 deletions(-) create mode 100644 src/DockerUpdateGuard.Data/Entities/VulnerabilityFixStatus.cs create mode 100644 src/DockerUpdateGuard.Data/Migrations/Update7.Designer.cs create mode 100644 src/DockerUpdateGuard.Data/Migrations/Update7.cs diff --git a/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs b/src/DockerUpdateGuard.Data/Configurations/VulnerabilityFindingConfiguration.cs index 5639cb5..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