Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
629 lines
28 KiB
C#
629 lines
28 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using StellaOps.Concelier.Merge.Options;
|
|
using StellaOps.Concelier.Merge.Services;
|
|
using StellaOps.Concelier.Models;
|
|
|
|
namespace StellaOps.Concelier.Merge.Tests;
|
|
|
|
public sealed class AdvisoryPrecedenceMergerTests
|
|
{
|
|
[Fact]
|
|
public void Merge_PrefersVendorPrecedenceOverNvd()
|
|
{
|
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
|
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
|
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
|
|
|
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
|
var expectedMergeTimestamp = timeProvider.GetUtcNow();
|
|
|
|
var merged = merger.Merge(new[] { nvd, redHat }).Advisory;
|
|
|
|
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
|
Assert.Equal("Red Hat Security Advisory", merged.Title);
|
|
Assert.Equal("Vendor-confirmed impact on RHEL 9.", merged.Summary);
|
|
Assert.Equal("high", merged.Severity);
|
|
Assert.Equal(redHat.Published, merged.Published);
|
|
Assert.Equal(redHat.Modified, merged.Modified);
|
|
Assert.Contains("RHSA-2025:0001", merged.Aliases);
|
|
Assert.Contains("CVE-2025-1000", merged.Aliases);
|
|
|
|
var package = Assert.Single(merged.AffectedPackages);
|
|
Assert.Equal("cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*", package.Identifier);
|
|
Assert.Empty(package.VersionRanges); // NVD range suppressed by vendor precedence
|
|
Assert.Contains(package.Statuses, status => status.Status == "known_affected");
|
|
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
|
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
|
|
|
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "redhat");
|
|
Assert.Contains(merged.CvssMetrics, metric => metric.Provenance.Source == "nvd");
|
|
|
|
var mergeProvenance = merged.Provenance.Single(p => p.Source == "merge");
|
|
Assert.Equal("precedence", mergeProvenance.Kind);
|
|
Assert.Equal(expectedMergeTimestamp, mergeProvenance.RecordedAt);
|
|
Assert.Contains("redhat", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("nvd", mergeProvenance.Value, StringComparison.OrdinalIgnoreCase);
|
|
|
|
var rangeMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
|
|
Assert.Equal(1, rangeMeasurement.Value);
|
|
Assert.Contains(rangeMeasurement.Tags, tag => string.Equals(tag.Key, "suppressed_source", StringComparison.Ordinal) && tag.Value?.ToString()?.Contains("nvd", StringComparison.OrdinalIgnoreCase) == true);
|
|
|
|
var severityConflict = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
|
|
Assert.Equal(1, severityConflict.Value);
|
|
Assert.Contains(severityConflict.Tags, tag => string.Equals(tag.Key, "type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void Merge_KevOnlyTogglesExploitKnown()
|
|
{
|
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero));
|
|
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
|
|
|
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd", timeProvider.GetUtcNow());
|
|
var baseAdvisory = new Advisory(
|
|
"CVE-2025-2000",
|
|
"CVE-2025-2000",
|
|
"Base registry summary",
|
|
"en",
|
|
new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero),
|
|
new DateTimeOffset(2025, 1, 6, 0, 0, 0, TimeSpan.Zero),
|
|
"medium",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-2000" },
|
|
credits: Array.Empty<AdvisoryCredit>(),
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: new[]
|
|
{
|
|
new AffectedPackage(
|
|
AffectedPackageTypes.Cpe,
|
|
"cpe:2.3:a:example:product:2.0:*:*:*:*:*:*:*",
|
|
null,
|
|
new[]
|
|
{
|
|
new AffectedVersionRange(
|
|
"semver",
|
|
"2.0.0",
|
|
"2.0.5",
|
|
null,
|
|
"<2.0.5",
|
|
new AdvisoryProvenance("nvd", "cpe_match", "product", timeProvider.GetUtcNow()))
|
|
},
|
|
Array.Empty<AffectedPackageStatus>(),
|
|
new[] { nvdProvenance })
|
|
},
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { nvdProvenance });
|
|
|
|
var kevProvenance = new AdvisoryProvenance("kev", "catalog", "CVE-2025-2000", timeProvider.GetUtcNow());
|
|
var kevAdvisory = new Advisory(
|
|
"CVE-2025-2000",
|
|
"Known Exploited Vulnerability",
|
|
summary: null,
|
|
language: null,
|
|
published: null,
|
|
modified: null,
|
|
severity: null,
|
|
exploitKnown: true,
|
|
aliases: new[] { "KEV-CVE-2025-2000" },
|
|
credits: Array.Empty<AdvisoryCredit>(),
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { kevProvenance });
|
|
|
|
var merged = merger.Merge(new[] { baseAdvisory, kevAdvisory }).Advisory;
|
|
|
|
Assert.True(merged.ExploitKnown);
|
|
Assert.Equal("medium", merged.Severity); // KEV must not override severity
|
|
Assert.Equal("Base registry summary", merged.Summary);
|
|
Assert.Contains("CVE-2025-2000", merged.Aliases);
|
|
Assert.Contains("KEV-CVE-2025-2000", merged.Aliases);
|
|
Assert.Contains(merged.Provenance, provenance => provenance.Source == "kev");
|
|
Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge");
|
|
}
|
|
|
|
[Fact]
|
|
public void Merge_UnionsCreditsFromSources()
|
|
{
|
|
var timeProvider = new FakeTimeProvider();
|
|
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
|
|
|
var ghsaCredits = new[]
|
|
{
|
|
new AdvisoryCredit(
|
|
displayName: "maintainer-team",
|
|
role: "remediation_developer",
|
|
contacts: new[] { "https://github.com/maintainer-team" },
|
|
provenance: new AdvisoryProvenance(
|
|
"ghsa",
|
|
"credit",
|
|
"mantainer-team",
|
|
timeProvider.GetUtcNow(),
|
|
new[] { ProvenanceFieldMasks.Credits })),
|
|
new AdvisoryCredit(
|
|
displayName: "security-reporter",
|
|
role: "reporter",
|
|
contacts: new[] { "https://github.com/security-reporter" },
|
|
provenance: new AdvisoryProvenance(
|
|
"ghsa",
|
|
"credit",
|
|
"security-reporter",
|
|
timeProvider.GetUtcNow(),
|
|
new[] { ProvenanceFieldMasks.Credits })),
|
|
};
|
|
|
|
var ghsa = new Advisory(
|
|
"CVE-2025-9000",
|
|
"GHSA advisory",
|
|
"Reported in GHSA",
|
|
"en",
|
|
timeProvider.GetUtcNow(),
|
|
timeProvider.GetUtcNow(),
|
|
"high",
|
|
exploitKnown: false,
|
|
aliases: new[] { "GHSA-aaaa-bbbb-cccc", "CVE-2025-9000" },
|
|
credits: ghsaCredits,
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-aaaa-bbbb-cccc", timeProvider.GetUtcNow(), new[] { ProvenanceFieldMasks.Advisory }) });
|
|
|
|
var osvCredits = new[]
|
|
{
|
|
new AdvisoryCredit(
|
|
displayName: "osv-researcher",
|
|
role: "reporter",
|
|
contacts: new[] { "mailto:osv-researcher@example.com" },
|
|
provenance: new AdvisoryProvenance(
|
|
"osv",
|
|
"credit",
|
|
"osv-researcher",
|
|
timeProvider.GetUtcNow(),
|
|
new[] { ProvenanceFieldMasks.Credits })),
|
|
new AdvisoryCredit(
|
|
displayName: "maintainer-team",
|
|
role: "remediation_developer",
|
|
contacts: new[] { "https://github.com/maintainer-team" },
|
|
provenance: new AdvisoryProvenance(
|
|
"osv",
|
|
"credit",
|
|
"maintainer-team",
|
|
timeProvider.GetUtcNow(),
|
|
new[] { ProvenanceFieldMasks.Credits })),
|
|
};
|
|
|
|
var osv = new Advisory(
|
|
"CVE-2025-9000",
|
|
"OSV advisory",
|
|
"Reported in OSV.dev",
|
|
"en",
|
|
timeProvider.GetUtcNow().AddDays(-1),
|
|
timeProvider.GetUtcNow().AddHours(-1),
|
|
"medium",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-9000" },
|
|
credits: osvCredits,
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: Array.Empty<AffectedPackage>(),
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { new AdvisoryProvenance("osv", "document", "https://osv.dev/vulnerability/CVE-2025-9000", timeProvider.GetUtcNow(), new[] { ProvenanceFieldMasks.Advisory }) });
|
|
|
|
var merged = merger.Merge(new[] { ghsa, osv }).Advisory;
|
|
|
|
Assert.Equal("CVE-2025-9000", merged.AdvisoryKey);
|
|
Assert.Contains(merged.Credits, credit =>
|
|
string.Equals(credit.DisplayName, "maintainer-team", StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(credit.Role, "remediation_developer", StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(merged.Credits, credit =>
|
|
string.Equals(credit.DisplayName, "osv-researcher", StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(credit.Role, "reporter", StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(merged.Credits, credit =>
|
|
string.Equals(credit.DisplayName, "security-reporter", StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(credit.Role, "reporter", StringComparison.OrdinalIgnoreCase));
|
|
|
|
Assert.Contains(merged.Credits, credit => credit.Provenance.Source == "ghsa");
|
|
Assert.Contains(merged.Credits, credit => credit.Provenance.Source == "osv");
|
|
}
|
|
|
|
[Fact]
|
|
public void Merge_AcscActsAsEnrichmentSource()
|
|
{
|
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero));
|
|
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
|
|
|
var vendorDocumentProvenance = new AdvisoryProvenance(
|
|
source: "vndr-cisco",
|
|
kind: "document",
|
|
value: "https://vendor.example/advisories/router-critical",
|
|
recordedAt: timeProvider.GetUtcNow(),
|
|
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
|
|
|
|
var vendorReference = new AdvisoryReference(
|
|
"https://vendor.example/advisories/router-critical",
|
|
kind: "advisory",
|
|
sourceTag: "vendor",
|
|
summary: "Vendor advisory",
|
|
provenance: new AdvisoryProvenance("vndr-cisco", "reference", "https://vendor.example/advisories/router-critical", timeProvider.GetUtcNow()));
|
|
|
|
var vendorPackage = new AffectedPackage(
|
|
AffectedPackageTypes.Vendor,
|
|
"ExampleCo Router X",
|
|
platform: null,
|
|
versionRanges: Array.Empty<AffectedVersionRange>(),
|
|
statuses: Array.Empty<AffectedPackageStatus>(),
|
|
normalizedVersions: Array.Empty<NormalizedVersionRule>(),
|
|
provenance: new[] { vendorDocumentProvenance });
|
|
|
|
var vendor = new Advisory(
|
|
advisoryKey: "acsc-2025-010",
|
|
title: "Vendor Critical Router Advisory",
|
|
summary: "Vendor-confirmed exploit.",
|
|
language: "en",
|
|
published: new DateTimeOffset(2025, 10, 11, 23, 0, 0, TimeSpan.Zero),
|
|
modified: new DateTimeOffset(2025, 10, 11, 23, 30, 0, TimeSpan.Zero),
|
|
severity: "critical",
|
|
exploitKnown: false,
|
|
aliases: new[] { "VENDOR-2025-010" },
|
|
references: new[] { vendorReference },
|
|
affectedPackages: new[] { vendorPackage },
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { vendorDocumentProvenance });
|
|
|
|
var acscDocumentProvenance = new AdvisoryProvenance(
|
|
source: "acsc",
|
|
kind: "document",
|
|
value: "https://origin.example/feeds/alerts/rss",
|
|
recordedAt: timeProvider.GetUtcNow(),
|
|
fieldMask: new[] { ProvenanceFieldMasks.Advisory });
|
|
|
|
var acscReference = new AdvisoryReference(
|
|
"https://origin.example/advisories/router-critical",
|
|
kind: "advisory",
|
|
sourceTag: "acsc",
|
|
summary: "ACSC alert",
|
|
provenance: new AdvisoryProvenance("acsc", "reference", "https://origin.example/advisories/router-critical", timeProvider.GetUtcNow()));
|
|
|
|
var acscPackage = new AffectedPackage(
|
|
AffectedPackageTypes.Vendor,
|
|
"ExampleCo Router X",
|
|
platform: null,
|
|
versionRanges: Array.Empty<AffectedVersionRange>(),
|
|
statuses: Array.Empty<AffectedPackageStatus>(),
|
|
normalizedVersions: Array.Empty<NormalizedVersionRule>(),
|
|
provenance: new[] { acscDocumentProvenance });
|
|
|
|
var acsc = new Advisory(
|
|
advisoryKey: "acsc-2025-010",
|
|
title: "ACSC Router Alert",
|
|
summary: "ACSC recommends installing vendor update.",
|
|
language: "en",
|
|
published: new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero),
|
|
modified: null,
|
|
severity: "medium",
|
|
exploitKnown: false,
|
|
aliases: new[] { "ACSC-2025-010" },
|
|
references: new[] { acscReference },
|
|
affectedPackages: new[] { acscPackage },
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[] { acscDocumentProvenance });
|
|
|
|
var merged = merger.Merge(new[] { acsc, vendor }).Advisory;
|
|
|
|
Assert.Equal("critical", merged.Severity); // ACSC must not override vendor severity
|
|
Assert.Equal("Vendor-confirmed exploit.", merged.Summary);
|
|
|
|
Assert.Contains("ACSC-2025-010", merged.Aliases);
|
|
Assert.Contains("VENDOR-2025-010", merged.Aliases);
|
|
|
|
Assert.Contains(merged.References, reference => reference.SourceTag == "vendor" && reference.Url == vendorReference.Url);
|
|
Assert.Contains(merged.References, reference => reference.SourceTag == "acsc" && reference.Url == acscReference.Url);
|
|
|
|
var enrichedPackage = Assert.Single(merged.AffectedPackages, package => package.Identifier == "ExampleCo Router X");
|
|
Assert.Contains(enrichedPackage.Provenance, provenance => provenance.Source == "vndr-cisco");
|
|
Assert.Contains(enrichedPackage.Provenance, provenance => provenance.Source == "acsc");
|
|
|
|
Assert.Contains(merged.Provenance, provenance => provenance.Source == "acsc");
|
|
Assert.Contains(merged.Provenance, provenance => provenance.Source == "vndr-cisco");
|
|
Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge" && (provenance.Value?.Contains("acsc", StringComparison.OrdinalIgnoreCase) ?? false));
|
|
}
|
|
|
|
[Fact]
|
|
public void Merge_RecordsNormalizedRuleMetrics()
|
|
{
|
|
var now = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero);
|
|
var timeProvider = new FakeTimeProvider(now);
|
|
var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider);
|
|
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
|
|
|
var normalizedRule = new NormalizedVersionRule(
|
|
NormalizedVersionSchemes.SemVer,
|
|
NormalizedVersionRuleTypes.Range,
|
|
min: "1.0.0",
|
|
minInclusive: true,
|
|
max: "2.0.0",
|
|
maxInclusive: false,
|
|
notes: "ghsa:GHSA-xxxx-yyyy");
|
|
|
|
var ghsaProvenance = new AdvisoryProvenance("ghsa", "package", "pkg:npm/example", now);
|
|
var ghsaPackage = new AffectedPackage(
|
|
AffectedPackageTypes.SemVer,
|
|
"pkg:npm/example",
|
|
platform: null,
|
|
versionRanges: new[]
|
|
{
|
|
new AffectedVersionRange(
|
|
NormalizedVersionSchemes.SemVer,
|
|
"1.0.0",
|
|
"2.0.0",
|
|
null,
|
|
">= 1.0.0 < 2.0.0",
|
|
ghsaProvenance)
|
|
},
|
|
statuses: Array.Empty<AffectedPackageStatus>(),
|
|
provenance: new[]
|
|
{
|
|
ghsaProvenance,
|
|
},
|
|
normalizedVersions: new[] { normalizedRule });
|
|
|
|
var nvdPackage = new AffectedPackage(
|
|
AffectedPackageTypes.SemVer,
|
|
"pkg:npm/example",
|
|
platform: null,
|
|
versionRanges: new[]
|
|
{
|
|
new AffectedVersionRange(
|
|
NormalizedVersionSchemes.SemVer,
|
|
"1.0.0",
|
|
"2.0.0",
|
|
null,
|
|
">= 1.0.0 < 2.0.0",
|
|
new AdvisoryProvenance("nvd", "cpe_match", "pkg:npm/example", now))
|
|
},
|
|
statuses: Array.Empty<AffectedPackageStatus>(),
|
|
provenance: new[]
|
|
{
|
|
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
|
|
},
|
|
normalizedVersions: Array.Empty<NormalizedVersionRule>());
|
|
|
|
var nvdExclusivePackage = new AffectedPackage(
|
|
AffectedPackageTypes.SemVer,
|
|
"pkg:npm/another",
|
|
platform: null,
|
|
versionRanges: new[]
|
|
{
|
|
new AffectedVersionRange(
|
|
NormalizedVersionSchemes.SemVer,
|
|
"3.0.0",
|
|
null,
|
|
null,
|
|
">= 3.0.0",
|
|
new AdvisoryProvenance("nvd", "cpe_match", "pkg:npm/another", now))
|
|
},
|
|
statuses: Array.Empty<AffectedPackageStatus>(),
|
|
provenance: new[]
|
|
{
|
|
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
|
|
},
|
|
normalizedVersions: Array.Empty<NormalizedVersionRule>());
|
|
|
|
var ghsaAdvisory = new Advisory(
|
|
"CVE-2025-7000",
|
|
"GHSA advisory",
|
|
"GHSA summary",
|
|
"en",
|
|
now,
|
|
now,
|
|
"high",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-7000", "GHSA-xxxx-yyyy" },
|
|
credits: Array.Empty<AdvisoryCredit>(),
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: new[] { ghsaPackage },
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[]
|
|
{
|
|
new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-xxxx-yyyy", now),
|
|
});
|
|
|
|
var nvdAdvisory = new Advisory(
|
|
"CVE-2025-7000",
|
|
"NVD entry",
|
|
"NVD summary",
|
|
"en",
|
|
now,
|
|
now,
|
|
"high",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-7000" },
|
|
credits: Array.Empty<AdvisoryCredit>(),
|
|
references: Array.Empty<AdvisoryReference>(),
|
|
affectedPackages: new[] { nvdPackage, nvdExclusivePackage },
|
|
cvssMetrics: Array.Empty<CvssMetric>(),
|
|
provenance: new[]
|
|
{
|
|
new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now),
|
|
});
|
|
|
|
var merged = merger.Merge(new[] { nvdAdvisory, ghsaAdvisory }).Advisory;
|
|
Assert.Equal(2, merged.AffectedPackages.Length);
|
|
|
|
var normalizedPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/example");
|
|
Assert.Single(normalizedPackage.NormalizedVersions);
|
|
|
|
var missingPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/another");
|
|
Assert.Empty(missingPackage.NormalizedVersions);
|
|
Assert.NotEmpty(missingPackage.VersionRanges);
|
|
|
|
var normalizedMeasurements = metrics.Measurements.Where(m => m.Name == "concelier.merge.normalized_rules").ToList();
|
|
Assert.Contains(normalizedMeasurements, measurement =>
|
|
measurement.Value == 1
|
|
&& measurement.Tags.Any(tag => string.Equals(tag.Key, "scheme", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal))
|
|
&& measurement.Tags.Any(tag => string.Equals(tag.Key, "package_type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal)));
|
|
|
|
var missingMeasurements = metrics.Measurements.Where(m => m.Name == "concelier.merge.normalized_rules_missing").ToList();
|
|
var missingMeasurement = Assert.Single(missingMeasurements);
|
|
Assert.Equal(1, missingMeasurement.Value);
|
|
Assert.Contains(missingMeasurement.Tags, tag => string.Equals(tag.Key, "package_type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Merge_RespectsConfiguredPrecedenceOverrides()
|
|
{
|
|
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero));
|
|
var options = new AdvisoryPrecedenceOptions
|
|
{
|
|
Ranks = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["nvd"] = 0,
|
|
["redhat"] = 5,
|
|
}
|
|
};
|
|
|
|
var logger = new TestLogger<AdvisoryPrecedenceMerger>();
|
|
using var metrics = new MetricCollector("StellaOps.Concelier.Merge");
|
|
|
|
var merger = new AdvisoryPrecedenceMerger(
|
|
new AffectedPackagePrecedenceResolver(),
|
|
options,
|
|
timeProvider,
|
|
logger);
|
|
|
|
var (redHat, nvd) = CreateVendorAndRegistryAdvisories();
|
|
var merged = merger.Merge(new[] { redHat, nvd }).Advisory;
|
|
|
|
Assert.Equal("CVE-2025-1000", merged.AdvisoryKey);
|
|
Assert.Equal("CVE-2025-1000", merged.Title); // NVD preferred
|
|
Assert.Equal("NVD summary", merged.Summary);
|
|
Assert.Equal("medium", merged.Severity);
|
|
|
|
var package = Assert.Single(merged.AffectedPackages);
|
|
Assert.NotEmpty(package.VersionRanges); // Vendor range no longer overrides
|
|
Assert.Contains(package.Provenance, provenance => provenance.Source == "nvd");
|
|
Assert.Contains(package.Provenance, provenance => provenance.Source == "redhat");
|
|
|
|
var overrideMeasurement = Assert.Single(metrics.Measurements, m => m.Name == "concelier.merge.overrides");
|
|
Assert.Equal(1, overrideMeasurement.Value);
|
|
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "primary_source" && string.Equals(tag.Value?.ToString(), "nvd", StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(overrideMeasurement.Tags, tag => tag.Key == "suppressed_source" && tag.Value?.ToString()?.Contains("redhat", StringComparison.OrdinalIgnoreCase) == true);
|
|
|
|
Assert.DoesNotContain(metrics.Measurements, measurement => measurement.Name == "concelier.merge.range_overrides");
|
|
|
|
var conflictMeasurement = Assert.Single(metrics.Measurements, measurement => measurement.Name == "concelier.merge.conflicts");
|
|
Assert.Equal(1, conflictMeasurement.Value);
|
|
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "type" && string.Equals(tag.Value?.ToString(), "severity", StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(conflictMeasurement.Tags, tag => tag.Key == "reason" && string.Equals(tag.Value?.ToString(), "mismatch", StringComparison.OrdinalIgnoreCase));
|
|
|
|
var logEntry = Assert.Single(logger.Entries, entry => entry.EventId.Name == "AdvisoryOverride");
|
|
Assert.Equal(LogLevel.Information, logEntry.Level);
|
|
Assert.NotNull(logEntry.StructuredState);
|
|
Assert.Contains(logEntry.StructuredState!, kvp =>
|
|
(string.Equals(kvp.Key, "Override", StringComparison.Ordinal) ||
|
|
string.Equals(kvp.Key, "@Override", StringComparison.Ordinal)) &&
|
|
kvp.Value is not null);
|
|
}
|
|
|
|
private static (Advisory Vendor, Advisory Registry) CreateVendorAndRegistryAdvisories()
|
|
{
|
|
var redHatPublished = new DateTimeOffset(2025, 1, 10, 0, 0, 0, TimeSpan.Zero);
|
|
var redHatModified = redHatPublished.AddDays(1);
|
|
var redHatProvenance = new AdvisoryProvenance("redhat", "advisory", "RHSA-2025:0001", redHatModified);
|
|
var redHatPackage = new AffectedPackage(
|
|
AffectedPackageTypes.Cpe,
|
|
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
|
"rhel-9",
|
|
Array.Empty<AffectedVersionRange>(),
|
|
new[] { new AffectedPackageStatus("known_affected", redHatProvenance) },
|
|
new[] { redHatProvenance });
|
|
var redHat = new Advisory(
|
|
"CVE-2025-1000",
|
|
"Red Hat Security Advisory",
|
|
"Vendor-confirmed impact on RHEL 9.",
|
|
"en",
|
|
redHatPublished,
|
|
redHatModified,
|
|
"high",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-1000", "RHSA-2025:0001" },
|
|
credits: Array.Empty<AdvisoryCredit>(),
|
|
references: new[]
|
|
{
|
|
new AdvisoryReference(
|
|
"https://access.redhat.com/errata/RHSA-2025:0001",
|
|
"advisory",
|
|
"redhat",
|
|
"Red Hat errata",
|
|
redHatProvenance)
|
|
},
|
|
affectedPackages: new[] { redHatPackage },
|
|
cvssMetrics: new[]
|
|
{
|
|
new CvssMetric(
|
|
"3.1",
|
|
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
9.8,
|
|
"critical",
|
|
new AdvisoryProvenance("redhat", "cvss", "RHSA-2025:0001", redHatModified))
|
|
},
|
|
provenance: new[] { redHatProvenance });
|
|
|
|
var nvdPublished = new DateTimeOffset(2025, 1, 5, 0, 0, 0, TimeSpan.Zero);
|
|
var nvdModified = nvdPublished.AddDays(2);
|
|
var nvdProvenance = new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-1000", nvdModified);
|
|
var nvdPackage = new AffectedPackage(
|
|
AffectedPackageTypes.Cpe,
|
|
"cpe:2.3:o:redhat:enterprise_linux:9:*:*:*:*:*:*:*",
|
|
"rhel-9",
|
|
new[]
|
|
{
|
|
new AffectedVersionRange(
|
|
"cpe",
|
|
null,
|
|
null,
|
|
null,
|
|
"<=9.0",
|
|
new AdvisoryProvenance("nvd", "cpe_match", "RHEL", nvdModified))
|
|
},
|
|
Array.Empty<AffectedPackageStatus>(),
|
|
new[] { nvdProvenance });
|
|
var nvd = new Advisory(
|
|
"CVE-2025-1000",
|
|
"CVE-2025-1000",
|
|
"NVD summary",
|
|
"en",
|
|
nvdPublished,
|
|
nvdModified,
|
|
"medium",
|
|
exploitKnown: false,
|
|
aliases: new[] { "CVE-2025-1000" },
|
|
credits: Array.Empty<AdvisoryCredit>(),
|
|
references: new[]
|
|
{
|
|
new AdvisoryReference(
|
|
"https://nvd.nist.gov/vuln/detail/CVE-2025-1000",
|
|
"advisory",
|
|
"nvd",
|
|
"NVD advisory",
|
|
nvdProvenance)
|
|
},
|
|
affectedPackages: new[] { nvdPackage },
|
|
cvssMetrics: new[]
|
|
{
|
|
new CvssMetric(
|
|
"3.1",
|
|
"CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N",
|
|
6.8,
|
|
"medium",
|
|
new AdvisoryProvenance("nvd", "cvss", "CVE-2025-1000", nvdModified))
|
|
},
|
|
provenance: new[] { nvdProvenance });
|
|
|
|
return (redHat, nvd);
|
|
}
|
|
}
|