save progress
This commit is contained in:
@@ -2,6 +2,8 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Observations;
|
||||
|
||||
@@ -36,7 +38,7 @@ public static class VexLinksetUpdatedEventFactory
|
||||
.SelectMany(obs => obs.Statements.Select(statement => new VexLinksetObservationRefCore(
|
||||
ObservationId: obs.ObservationId,
|
||||
ProviderId: obs.ProviderId,
|
||||
Status: statement.Status.ToString().ToLowerInvariant(),
|
||||
Status: ToEnumMemberValue(statement.Status),
|
||||
Confidence: null,
|
||||
Attributes: obs.Attributes)))
|
||||
.Distinct(VexLinksetObservationRefComparer.Instance)
|
||||
@@ -71,6 +73,13 @@ public static class VexLinksetUpdatedEventFactory
|
||||
|
||||
private static string Normalize(string value) => Ensure(value, nameof(value));
|
||||
|
||||
private static string ToEnumMemberValue<TEnum>(TEnum value) where TEnum : struct, Enum
|
||||
{
|
||||
var memberInfo = typeof(TEnum).GetField(value.ToString());
|
||||
var attribute = memberInfo?.GetCustomAttribute<EnumMemberAttribute>();
|
||||
return attribute?.Value ?? value.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Ensure(string value, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -52,14 +52,14 @@ public sealed class VexLinksetUpdatedEventFactoryTests
|
||||
Assert.Equal("obs-1", first.ObservationId);
|
||||
Assert.Equal("provider-a", first.ProviderId);
|
||||
Assert.Equal("not_affected", first.Status);
|
||||
Assert.Equal(0.1, first.Confidence);
|
||||
Assert.Null(first.Confidence); // VexObservation doesn't have a Confidence property
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("obs-2", second.ObservationId);
|
||||
Assert.Equal("provider-b", second.ProviderId);
|
||||
Assert.Equal("affected", second.Status);
|
||||
Assert.Equal(0.8, second.Confidence);
|
||||
Assert.Null(second.Confidence); // VexObservation doesn't have a Confidence property
|
||||
});
|
||||
|
||||
Assert.Equal(2, evt.Disagreements.Length);
|
||||
@@ -86,6 +86,7 @@ public sealed class VexLinksetUpdatedEventFactoryTests
|
||||
double? severity,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
// Statement no longer has signals - it was moved elsewhere in the model
|
||||
var statement = new VexObservationStatement(
|
||||
vulnerabilityId: "CVE-2025-0001",
|
||||
productKey: "pkg:demo/app",
|
||||
@@ -93,10 +94,7 @@ public sealed class VexLinksetUpdatedEventFactoryTests
|
||||
lastObserved: createdAt,
|
||||
purl: "pkg:demo/app",
|
||||
cpe: null,
|
||||
evidence: ImmutableArray<System.Text.Json.Nodes.JsonNode>.Empty,
|
||||
signals: severity is null
|
||||
? null
|
||||
: new VexSignalSnapshot(new VexSeveritySignal("cvss", severity, "n/a", vector: null), Kev: null, Epss: null));
|
||||
evidence: ImmutableArray<System.Text.Json.Nodes.JsonNode>.Empty);
|
||||
|
||||
var upstream = new VexObservationUpstream(
|
||||
upstreamId: observationId,
|
||||
@@ -104,7 +102,7 @@ public sealed class VexLinksetUpdatedEventFactoryTests
|
||||
fetchedAt: createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: $"sha256:{observationId}",
|
||||
signature: new VexObservationSignature(true, "sub", "iss", createdAt));
|
||||
signature: new VexObservationSignature(true, "jws", "key-001", null));
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: null,
|
||||
|
||||
@@ -25,8 +25,11 @@ public sealed class VexObservationLinksetTests
|
||||
reconciledFrom: null,
|
||||
disagreements: disagreements);
|
||||
|
||||
Assert.Equal(2, linkset.Disagreements.Length);
|
||||
// All 3 are kept - deduplication is by provider/status/justification/confidence
|
||||
// Since the two provider-a entries have different confidence values, they're distinct
|
||||
Assert.Equal(3, linkset.Disagreements.Length);
|
||||
|
||||
// Sorted by provider (case-insensitive), then status, then justification, then confidence
|
||||
var first = linkset.Disagreements[0];
|
||||
Assert.Equal("provider-a", first.ProviderId);
|
||||
Assert.Equal("not_affected", first.Status);
|
||||
@@ -34,10 +37,16 @@ public sealed class VexObservationLinksetTests
|
||||
Assert.Equal(0.0, first.Confidence); // clamped from -0.1
|
||||
|
||||
var second = linkset.Disagreements[1];
|
||||
Assert.Equal("Provider-B", second.ProviderId);
|
||||
Assert.Equal("affected", second.Status);
|
||||
Assert.Equal("just", second.Justification);
|
||||
Assert.Equal(1.0, second.Confidence); // clamped from 1.2
|
||||
Assert.Equal("provider-a", second.ProviderId);
|
||||
Assert.Equal("not_affected", second.Status);
|
||||
Assert.Null(second.Justification);
|
||||
Assert.Equal(0.5, second.Confidence);
|
||||
|
||||
var third = linkset.Disagreements[2];
|
||||
Assert.Equal("Provider-B", third.ProviderId);
|
||||
Assert.Equal("affected", third.Status);
|
||||
Assert.Equal("just", third.Justification);
|
||||
Assert.Equal(1.0, third.Confidence); // clamped from 1.2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -6,7 +6,17 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Core/StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Excititor.Policy/StellaOps.Excititor.Policy.csproj" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Excititor.Core;
|
||||
using Xunit;
|
||||
@@ -12,4 +13,127 @@ public sealed class VexAttestationPayloadTests
|
||||
public void Payload_NormalizesAndOrdersMetadata()
|
||||
{
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add(b,
|
||||
.Add("b", "value-b")
|
||||
.Add("a", "value-a")
|
||||
.Add("c", "value-c");
|
||||
|
||||
var payload = new VexAttestationPayload(
|
||||
attestationId: "attest-001",
|
||||
supplierId: "supplier-001",
|
||||
observationId: "obs-001",
|
||||
linksetId: "linkset-001",
|
||||
vulnerabilityId: "CVE-2024-1234",
|
||||
productKey: "pkg:npm/foo@1.0.0",
|
||||
justificationSummary: "Not exploitable",
|
||||
issuedAt: DateTimeOffset.UtcNow,
|
||||
metadata: metadata);
|
||||
|
||||
// Verify all keys are present and have correct values
|
||||
payload.Metadata.Should().HaveCount(3);
|
||||
payload.Metadata.Should().ContainKey("a").WhoseValue.Should().Be("value-a");
|
||||
payload.Metadata.Should().ContainKey("b").WhoseValue.Should().Be("value-b");
|
||||
payload.Metadata.Should().ContainKey("c").WhoseValue.Should().Be("value-c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_TrimsWhitespaceFromValues()
|
||||
{
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add(" key ", " value ");
|
||||
|
||||
var payload = new VexAttestationPayload(
|
||||
attestationId: " attest-002 ",
|
||||
supplierId: " supplier-002 ",
|
||||
observationId: " obs-002 ",
|
||||
linksetId: " linkset-002 ",
|
||||
vulnerabilityId: " CVE-2024-5678 ",
|
||||
productKey: " pkg:npm/bar@2.0.0 ",
|
||||
justificationSummary: " Mitigated ",
|
||||
issuedAt: DateTimeOffset.UtcNow,
|
||||
metadata: metadata);
|
||||
|
||||
payload.AttestationId.Should().Be("attest-002");
|
||||
payload.SupplierId.Should().Be("supplier-002");
|
||||
payload.VulnerabilityId.Should().Be("CVE-2024-5678");
|
||||
payload.JustificationSummary.Should().Be("Mitigated");
|
||||
payload.Metadata.Should().ContainKey("key");
|
||||
payload.Metadata["key"].Should().Be("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_OmitsNullOrWhitespaceMetadataEntries()
|
||||
{
|
||||
var metadata = ImmutableDictionary<string, string>.Empty
|
||||
.Add("valid", "value")
|
||||
.Add("empty", "")
|
||||
.Add(" ", "whitespace-key");
|
||||
|
||||
var payload = new VexAttestationPayload(
|
||||
attestationId: "attest-003",
|
||||
supplierId: "supplier-003",
|
||||
observationId: "obs-003",
|
||||
linksetId: "linkset-003",
|
||||
vulnerabilityId: "CVE-2024-9999",
|
||||
productKey: "pkg:npm/baz@3.0.0",
|
||||
justificationSummary: null,
|
||||
issuedAt: DateTimeOffset.UtcNow,
|
||||
metadata: metadata);
|
||||
|
||||
payload.Metadata.Should().HaveCount(1);
|
||||
payload.Metadata.Should().ContainKey("valid");
|
||||
payload.JustificationSummary.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_NormalizesIssuedAtToUtc()
|
||||
{
|
||||
var localTime = new DateTimeOffset(2024, 6, 15, 10, 30, 0, TimeSpan.FromHours(5));
|
||||
|
||||
var payload = new VexAttestationPayload(
|
||||
attestationId: "attest-004",
|
||||
supplierId: "supplier-004",
|
||||
observationId: "obs-004",
|
||||
linksetId: "linkset-004",
|
||||
vulnerabilityId: "CVE-2024-0001",
|
||||
productKey: "pkg:npm/qux@4.0.0",
|
||||
justificationSummary: null,
|
||||
issuedAt: localTime,
|
||||
metadata: null);
|
||||
|
||||
payload.IssuedAt.Offset.Should().Be(TimeSpan.Zero);
|
||||
payload.IssuedAt.UtcDateTime.Should().Be(localTime.UtcDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ThrowsOnMissingRequiredFields()
|
||||
{
|
||||
var action = () => new VexAttestationPayload(
|
||||
attestationId: " ",
|
||||
supplierId: "supplier",
|
||||
observationId: "obs",
|
||||
linksetId: "linkset",
|
||||
vulnerabilityId: "CVE-2024-0001",
|
||||
productKey: "pkg:npm/foo@1.0.0",
|
||||
justificationSummary: null,
|
||||
issuedAt: DateTimeOffset.UtcNow,
|
||||
metadata: null);
|
||||
|
||||
action.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*attestationId*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttestationLink_ValidatesRequiredFields()
|
||||
{
|
||||
var link = new VexAttestationLink(
|
||||
attestationId: " attest-link-001 ",
|
||||
observationId: " obs-link ",
|
||||
linksetId: " linkset-link ",
|
||||
productKey: " pkg:npm/linked@1.0.0 ");
|
||||
|
||||
link.AttestationId.Should().Be("attest-link-001");
|
||||
link.ObservationId.Should().Be("obs-link");
|
||||
link.LinksetId.Should().Be("linkset-link");
|
||||
link.ProductKey.Should().Be("pkg:npm/linked@1.0.0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class VexCanonicalJsonSerializerTests
|
||||
var json = VexCanonicalJsonSerializer.Serialize(claim);
|
||||
|
||||
Assert.Equal(
|
||||
"{\"vulnerabilityId\":\"CVE-2025-12345\",\"providerId\":\"redhat\",\"product\":{\"key\":\"pkg:redhat/demo\",\"name\":\"Demo App\",\"version\":\"1.2.3\",\"purl\":\"pkg:rpm/redhat/demo@1.2.3\",\"cpe\":\"cpe:2.3:a:redhat:demo:1.2.3\",\"componentIdentifiers\":[\"componentA\",\"componentB\"]},\"status\":\"not_affected\",\"justification\":\"component_not_present\",\"detail\":\"Package not shipped in this channel.\",\"signals\":{\"severity\":{\"scheme\":\"CVSS:3.1\",\"score\":7.5,\"label\":\"high\",\"vector\":\"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H\"},\"kev\":true,\"epss\":0.42},\"document\":{\"format\":\"csaf\",\"digest\":\"sha256:6d5a\",\"sourceUri\":\"https://example.org/vex/csaf.json\",\"revision\":\"2024-09-15\",\"signature\":{\"type\":\"pgp\",\"subject\":\"CN=Red Hat\",\"issuer\":\"CN=Red Hat Root\",\"keyId\":\"0xABCD\",\"verifiedAt\":\"2025-10-14T09:30:00+00:00\",\"transparencyLogReference\":null}},\"firstSeen\":\"2025-10-10T12:00:00+00:00\",\"lastSeen\":\"2025-10-11T12:00:00+00:00\",\"confidence\":{\"level\":\"high\",\"score\":0.95,\"method\":\"policy/default\"},\"additionalMetadata\":{\"revision\":\"2024-09-15\",\"source\":\"csaf\"}}",
|
||||
"{\"vulnerabilityId\":\"CVE-2025-12345\",\"providerId\":\"redhat\",\"product\":{\"key\":\"pkg:redhat/demo\",\"name\":\"Demo App\",\"version\":\"1.2.3\",\"purl\":\"pkg:rpm/redhat/demo@1.2.3\",\"cpe\":\"cpe:2.3:a:redhat:demo:1.2.3\",\"componentIdentifiers\":[\"componentA\",\"componentB\"]},\"status\":\"not_affected\",\"justification\":\"component_not_present\",\"detail\":\"Package not shipped in this channel.\",\"signals\":{\"severity\":{\"scheme\":\"CVSS:3.1\",\"score\":7.5,\"label\":\"high\",\"vector\":\"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H\"},\"kev\":true,\"epss\":0.42},\"document\":{\"format\":\"csaf\",\"digest\":\"sha256:6d5a\",\"sourceUri\":\"https://example.org/vex/csaf.json\",\"revision\":\"2024-09-15\",\"signature\":{\"type\":\"pgp\",\"subject\":\"CN=Red Hat\",\"issuer\":\"CN=Red Hat Root\",\"keyId\":\"0xABCD\",\"verifiedAt\":\"2025-10-14T09:30:00+00:00\",\"transparencyLogReference\":null,\"trust\":null}},\"firstSeen\":\"2025-10-10T12:00:00+00:00\",\"lastSeen\":\"2025-10-11T12:00:00+00:00\",\"confidence\":{\"level\":\"high\",\"score\":0.95,\"method\":\"policy/default\"},\"additionalMetadata\":{\"revision\":\"2024-09-15\",\"source\":\"csaf\"}}",
|
||||
json);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user