feat: Add initial implementation of Vulnerability Resolver Jobs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Created project for StellaOps.Scanner.Analyzers.Native.Tests with necessary dependencies. - Documented roles and guidelines in AGENTS.md for Scheduler module. - Implemented IResolverJobService interface and InMemoryResolverJobService for handling resolver jobs. - Added ResolverBacklogNotifier and ResolverBacklogService for monitoring job metrics. - Developed API endpoints for managing resolver jobs and retrieving metrics. - Defined models for resolver job requests and responses. - Integrated dependency injection for resolver job services. - Implemented ImpactIndexSnapshot for persisting impact index data. - Introduced SignalsScoringOptions for configurable scoring weights in reachability scoring. - Added unit tests for ReachabilityScoringService and RuntimeFactsIngestionService. - Created dotnet-filter.sh script to handle command-line arguments for dotnet. - Established nuget-prime project for managing package downloads.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests.Observations;
|
||||
|
||||
public sealed class VexLinksetUpdatedEventFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_Normalizes_Sorts_And_Deduplicates()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var observations = new List<VexObservation>
|
||||
{
|
||||
CreateObservation("obs-2", "provider-b", VexClaimStatus.Affected, 0.8, now),
|
||||
CreateObservation("obs-1", "provider-a", VexClaimStatus.NotAffected, 0.1, now),
|
||||
CreateObservation("obs-1", "provider-a", VexClaimStatus.NotAffected, 0.1, now), // duplicate
|
||||
};
|
||||
|
||||
var disagreements = new[]
|
||||
{
|
||||
new VexObservationDisagreement("provider-b", "affected", "reason", 1.2),
|
||||
new VexObservationDisagreement("provider-b", "affected", "reason", 1.2),
|
||||
new VexObservationDisagreement("provider-a", "not_affected", null, -0.5),
|
||||
};
|
||||
|
||||
var evt = VexLinksetUpdatedEventFactory.Create(
|
||||
tenant: "TENANT",
|
||||
linksetId: "link-123",
|
||||
vulnerabilityId: "CVE-2025-0001",
|
||||
productKey: "pkg:demo/app",
|
||||
observations,
|
||||
disagreements,
|
||||
now);
|
||||
|
||||
Assert.Equal(VexLinksetUpdatedEventFactory.EventType, evt.EventType);
|
||||
Assert.Equal("tenant", evt.Tenant);
|
||||
Assert.Equal(2, evt.Observations.Length);
|
||||
Assert.Collection(evt.Observations,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("obs-1", first.ObservationId);
|
||||
Assert.Equal("provider-a", first.ProviderId);
|
||||
Assert.Equal("not_affected", first.Status);
|
||||
Assert.Equal(0.1, first.Confidence);
|
||||
},
|
||||
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.Equal(2, evt.Disagreements.Length);
|
||||
Assert.Collection(evt.Disagreements,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("provider-a", first.ProviderId);
|
||||
Assert.Equal("not_affected", first.Status);
|
||||
Assert.Equal(0.0, first.Confidence); // clamped
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("provider-b", second.ProviderId);
|
||||
Assert.Equal("affected", second.Status);
|
||||
Assert.Equal(1.0, second.Confidence); // clamped
|
||||
Assert.Equal("reason", second.Justification);
|
||||
});
|
||||
}
|
||||
|
||||
private static VexObservation CreateObservation(
|
||||
string observationId,
|
||||
string providerId,
|
||||
VexClaimStatus status,
|
||||
double? severity,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
var statement = new VexObservationStatement(
|
||||
vulnerabilityId: "CVE-2025-0001",
|
||||
productKey: "pkg:demo/app",
|
||||
status: status,
|
||||
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));
|
||||
|
||||
var upstream = new VexObservationUpstream(
|
||||
upstreamId: observationId,
|
||||
documentVersion: null,
|
||||
fetchedAt: createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: $"sha256:{observationId}",
|
||||
signature: new VexObservationSignature(true, "sub", "iss", createdAt));
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: null,
|
||||
purls: new[] { "pkg:demo/app" },
|
||||
cpes: null,
|
||||
references: null);
|
||||
|
||||
var content = new VexObservationContent(
|
||||
format: "csaf",
|
||||
specVersion: "2.0",
|
||||
raw: System.Text.Json.Nodes.JsonNode.Parse("{}")!);
|
||||
|
||||
return new VexObservation(
|
||||
observationId,
|
||||
tenant: "tenant",
|
||||
providerId,
|
||||
streamId: "stream",
|
||||
upstream,
|
||||
statements: ImmutableArray.Create(statement),
|
||||
content,
|
||||
linkset,
|
||||
createdAt,
|
||||
supersedes: ImmutableArray<string>.Empty,
|
||||
attributes: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.Tests.Observations;
|
||||
|
||||
public sealed class VexObservationLinksetTests
|
||||
{
|
||||
[Fact]
|
||||
public void Disagreements_Normalize_SortsAndClamps()
|
||||
{
|
||||
var disagreements = new[]
|
||||
{
|
||||
new VexObservationDisagreement("Provider-B", "affected", "just", 1.2),
|
||||
new VexObservationDisagreement("provider-a", "not_affected", null, -0.1),
|
||||
new VexObservationDisagreement("provider-a", "not_affected", null, 0.5),
|
||||
};
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: null,
|
||||
purls: null,
|
||||
cpes: null,
|
||||
references: null,
|
||||
reconciledFrom: null,
|
||||
disagreements: disagreements);
|
||||
|
||||
Assert.Equal(2, linkset.Disagreements.Length);
|
||||
|
||||
var first = linkset.Disagreements[0];
|
||||
Assert.Equal("provider-a", first.ProviderId);
|
||||
Assert.Equal("not_affected", first.Status);
|
||||
Assert.Null(first.Justification);
|
||||
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
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disagreements_Deduplicates_ByProviderStatusJustificationConfidence()
|
||||
{
|
||||
var disagreements = new List<VexObservationDisagreement>
|
||||
{
|
||||
new("provider-a", "affected", null, 0.7),
|
||||
new("provider-a", "affected", null, 0.7),
|
||||
new("provider-a", "affected", null, 0.7),
|
||||
};
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: null,
|
||||
purls: null,
|
||||
cpes: null,
|
||||
references: null,
|
||||
reconciledFrom: null,
|
||||
disagreements: disagreements);
|
||||
|
||||
Assert.Single(linkset.Disagreements);
|
||||
Assert.Equal(0.7, linkset.Disagreements[0].Confidence);
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,12 @@ public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
|
||||
[Fact]
|
||||
public async Task RunAsync_AppliesInitialIndexesOnce()
|
||||
{
|
||||
var migrations = new IVexMongoMigration[]
|
||||
{
|
||||
new VexInitialIndexMigration(),
|
||||
new VexConsensusSignalsMigration(),
|
||||
};
|
||||
var migrations = new IVexMongoMigration[]
|
||||
{
|
||||
new VexInitialIndexMigration(),
|
||||
new VexConsensusSignalsMigration(),
|
||||
new VexObservationCollectionsMigration(),
|
||||
};
|
||||
var runner = new VexMongoMigrationRunner(_database, migrations, NullLogger<VexMongoMigrationRunner>.Instance);
|
||||
|
||||
await runner.RunAsync(CancellationToken.None);
|
||||
@@ -33,8 +34,8 @@ public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
|
||||
|
||||
var appliedCollection = _database.GetCollection<VexMigrationRecord>(VexMongoCollectionNames.Migrations);
|
||||
var applied = await appliedCollection.Find(FilterDefinition<VexMigrationRecord>.Empty).ToListAsync();
|
||||
Assert.Equal(2, applied.Count);
|
||||
Assert.Equal(migrations.Select(m => m.Id).OrderBy(id => id, StringComparer.Ordinal), applied.Select(record => record.Id).OrderBy(id => id, StringComparer.Ordinal));
|
||||
Assert.Equal(3, applied.Count);
|
||||
Assert.Equal(migrations.Select(m => m.Id).OrderBy(id => id, StringComparer.Ordinal), applied.Select(record => record.Id).OrderBy(id => id, StringComparer.Ordinal));
|
||||
|
||||
Assert.True(HasIndex(_database.GetCollection<VexRawDocumentRecord>(VexMongoCollectionNames.Raw), "ProviderId_1_Format_1_RetrievedAt_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexProviderRecord>(VexMongoCollectionNames.Providers), "Kind_1"));
|
||||
@@ -43,11 +44,19 @@ public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
|
||||
Assert.True(HasIndex(_database.GetCollection<VexConsensusRecord>(VexMongoCollectionNames.Consensus), "PolicyRevisionId_1_CalculatedAt_-1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexExportManifestRecord>(VexMongoCollectionNames.Exports), "QuerySignature_1_Format_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "QuerySignature_1_Format_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "ExpiresAt_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "VulnerabilityId_1_Product.Key_1_InsertedAt_-1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "ProviderId_1_InsertedAt_-1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "Document.Digest_1"));
|
||||
}
|
||||
Assert.True(HasIndex(_database.GetCollection<VexCacheEntryRecord>(VexMongoCollectionNames.Cache), "ExpiresAt_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "VulnerabilityId_1_Product.Key_1_InsertedAt_-1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "ProviderId_1_InsertedAt_-1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexStatementRecord>(VexMongoCollectionNames.Statements), "Document.Digest_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_ObservationId_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_VulnerabilityId_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_ProductKey_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_Document.Digest_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexObservationRecord>(VexMongoCollectionNames.Observations), "Tenant_1_ProviderId_1_Status_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets), "Tenant_1_LinksetId_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets), "Tenant_1_VulnerabilityId_1"));
|
||||
Assert.True(HasIndex(_database.GetCollection<VexLinksetRecord>(VexMongoCollectionNames.Linksets), "Tenant_1_ProductKey_1"));
|
||||
}
|
||||
|
||||
private static bool HasIndex<TDocument>(IMongoCollection<TDocument> collection, string name)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Linq;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Telemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class EvidenceTelemetryTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordChunkOutcome_EmitsCounterAndHistogram()
|
||||
{
|
||||
var measurements = new List<(string Name, double Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)>();
|
||||
|
||||
using var listener = CreateListener((instrument, value, tags) =>
|
||||
{
|
||||
measurements.Add((instrument.Name, value, tags.ToList()));
|
||||
});
|
||||
|
||||
EvidenceTelemetry.RecordChunkOutcome("tenant-a", "success", chunkCount: 3, truncated: true);
|
||||
|
||||
Assert.Contains(measurements, m => m.Name == "excititor.vex.evidence.requests" && m.Value == 1);
|
||||
Assert.Contains(measurements, m => m.Name == "excititor.vex.evidence.chunk_count" && m.Value == 3);
|
||||
|
||||
var requestTags = measurements.First(m => m.Name == "excititor.vex.evidence.requests").Tags.ToDictionary(kv => kv.Key, kv => kv.Value);
|
||||
Assert.Equal("tenant-a", requestTags["tenant"]);
|
||||
Assert.Equal("success", requestTags["outcome"]);
|
||||
Assert.Equal(true, requestTags["truncated"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordChunkSignatureStatus_EmitsSignatureCounters()
|
||||
{
|
||||
var measurements = new List<(string Name, double Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)>();
|
||||
|
||||
using var listener = CreateListener((instrument, value, tags) =>
|
||||
{
|
||||
measurements.Add((instrument.Name, value, tags.ToList()));
|
||||
});
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var scope = new VexEvidenceChunkScope("pkg:demo/app", "demo", "1.0.0", "pkg:demo/app@1.0.0", null, new[] { "component-a" });
|
||||
var document = new VexEvidenceChunkDocument("digest-1", "spdx", "https://example.test/vex.json", "r1");
|
||||
|
||||
var chunks = new List<VexEvidenceChunkResponse>
|
||||
{
|
||||
new("obs-1", "link-1", "CVE-2025-0001", "pkg:demo/app", "provider-a", "Affected", "just", "detail", 0.9, now.AddMinutes(-10), now, scope, document, new VexEvidenceChunkSignature("cosign", "sub", "issuer", "kid", now, null), new Dictionary<string, string>()),
|
||||
new("obs-2", "link-2", "CVE-2025-0001", "pkg:demo/app", "provider-b", "NotAffected", null, null, null, now.AddMinutes(-8), now, scope, document, null, new Dictionary<string, string>()),
|
||||
new("obs-3", "link-3", "CVE-2025-0001", "pkg:demo/app", "provider-c", "Affected", null, null, null, now.AddMinutes(-6), now, scope, document, new VexEvidenceChunkSignature("cosign", "sub", "issuer", "kid", null, null), new Dictionary<string, string>()),
|
||||
};
|
||||
|
||||
EvidenceTelemetry.RecordChunkSignatureStatus("tenant-b", chunks);
|
||||
|
||||
AssertSignatureMeasurement(measurements, "unsigned", 1, "tenant-b");
|
||||
AssertSignatureMeasurement(measurements, "unverified", 1, "tenant-b");
|
||||
AssertSignatureMeasurement(measurements, "verified", 1, "tenant-b");
|
||||
}
|
||||
|
||||
private static MeterListener CreateListener(Action<Instrument, double, ReadOnlySpan<KeyValuePair<string, object?>>> callback)
|
||||
{
|
||||
var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter.Name == EvidenceTelemetry.MeterName)
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, _) => callback(instrument, measurement, tags));
|
||||
listener.SetMeasurementEventCallback<int>((instrument, measurement, tags, _) => callback(instrument, measurement, tags));
|
||||
|
||||
listener.Start();
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static void AssertSignatureMeasurement(IEnumerable<(string Name, double Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)> measurements, string status, int expectedValue, string tenant)
|
||||
{
|
||||
var match = measurements.FirstOrDefault(m => m.Name == "excititor.vex.signature.status" && m.Tags.Any(t => t.Key == "status" && (string?)t.Value == status));
|
||||
Assert.Equal(expectedValue, match.Value);
|
||||
Assert.Contains(match.Tags, t => t.Key == "tenant" && (string?)t.Value == tenant);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user