save progress

This commit is contained in:
StellaOps Bot
2025-12-20 12:15:16 +02:00
parent 439f10966b
commit 0ada1b583f
95 changed files with 12400 additions and 65 deletions

View File

@@ -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))

View File

@@ -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,

View File

@@ -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]

View File

@@ -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" />

View File

@@ -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");
}
}

View File

@@ -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);
}