feat: Implement policy attestation features and service account delegation
- Added new policy scopes: `policy:publish` and `policy:promote` with interactive-only enforcement. - Introduced metadata parameters for policy actions: `policy_reason`, `policy_ticket`, and `policy_digest`. - Enhanced token validation to require fresh authentication for policy attestation tokens. - Updated grant handlers to enforce policy scope checks and log audit information. - Implemented service account delegation configuration, including quotas and validation. - Seeded service accounts during application initialization based on configuration. - Updated documentation and tasks to reflect new features and changes.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Chunking;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class AdvisoryStructuredRetrieverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsCsafChunksWithMetadata()
|
||||
{
|
||||
var provider = CreateProvider(
|
||||
"test-advisory",
|
||||
AdvisoryDocument.Create(
|
||||
"CSA-2024-0001",
|
||||
DocumentFormat.Csaf,
|
||||
"csaf",
|
||||
await LoadAsync("sample-csaf.json")));
|
||||
|
||||
var retriever = CreateRetriever(provider);
|
||||
var result = await retriever.RetrieveAsync(new AdvisoryRetrievalRequest("test-advisory"), CancellationToken.None);
|
||||
|
||||
result.Chunks.Should().NotBeEmpty();
|
||||
result.Chunks.Should().HaveCountGreaterThan(4);
|
||||
result.Chunks.Select(c => c.ChunkId).Should().BeInAscendingOrder();
|
||||
result.Chunks.All(c => c.Metadata["format"] == "csaf").Should().BeTrue();
|
||||
result.Chunks.Any(c => c.Section == "vulnerabilities.remediations").Should().BeTrue();
|
||||
result.Chunks.Any(c => c.Section == "document.notes").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsOsvChunksWithAffectedMetadata()
|
||||
{
|
||||
var provider = CreateProvider(
|
||||
"osv-advisory",
|
||||
AdvisoryDocument.Create(
|
||||
"OSV-2024-0001",
|
||||
DocumentFormat.Osv,
|
||||
"osv",
|
||||
await LoadAsync("sample-osv.json")));
|
||||
|
||||
var retriever = CreateRetriever(provider);
|
||||
var result = await retriever.RetrieveAsync(new AdvisoryRetrievalRequest("osv-advisory"), CancellationToken.None);
|
||||
|
||||
result.Chunks.Should().NotBeEmpty();
|
||||
result.Chunks.Should().ContainSingle(c => c.Section == "summary");
|
||||
result.Chunks.Should().Contain(c => c.Section == "affected.ranges");
|
||||
result.Chunks.First(c => c.Section == "affected.ranges").Metadata.Should().ContainKey("package");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsOpenVexChunksWithStatusMetadata()
|
||||
{
|
||||
var provider = CreateProvider(
|
||||
"openvex-advisory",
|
||||
AdvisoryDocument.Create(
|
||||
"OPENVEX-2024-0001",
|
||||
DocumentFormat.OpenVex,
|
||||
"exc-provider",
|
||||
await LoadAsync("sample-openvex.json")));
|
||||
|
||||
var retriever = CreateRetriever(provider);
|
||||
var result = await retriever.RetrieveAsync(new AdvisoryRetrievalRequest("openvex-advisory"), CancellationToken.None);
|
||||
|
||||
result.Chunks.Should().HaveCount(2);
|
||||
result.Chunks.Select(c => c.Metadata["status"]).Should().Contain(new[] { "not_affected", "affected" });
|
||||
result.Chunks.First().Metadata.Should().ContainKey("justification");
|
||||
result.Chunks.Should().AllSatisfy(chunk => chunk.Section.Should().Be("vex.statements"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_FiltersToPreferredSections()
|
||||
{
|
||||
var provider = CreateProvider(
|
||||
"markdown-advisory",
|
||||
AdvisoryDocument.Create(
|
||||
"VENDOR-2024-0001",
|
||||
DocumentFormat.Markdown,
|
||||
"vendor",
|
||||
await LoadAsync("sample-vendor.md")));
|
||||
|
||||
var retriever = CreateRetriever(provider);
|
||||
var request = new AdvisoryRetrievalRequest(
|
||||
"markdown-advisory",
|
||||
PreferredSections: new[] { "Impact" });
|
||||
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.Chunks.Should().NotBeEmpty();
|
||||
result.Chunks.Should().OnlyContain(chunk => chunk.Section.StartsWith("Impact", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static AdvisoryStructuredRetriever CreateRetriever(IAdvisoryDocumentProvider provider)
|
||||
{
|
||||
var chunkers = new IDocumentChunker[]
|
||||
{
|
||||
new CsafDocumentChunker(),
|
||||
new OsvDocumentChunker(),
|
||||
new MarkdownDocumentChunker(),
|
||||
new OpenVexDocumentChunker(),
|
||||
};
|
||||
|
||||
return new AdvisoryStructuredRetriever(provider, chunkers, NullLogger<AdvisoryStructuredRetriever>.Instance);
|
||||
}
|
||||
|
||||
private static async Task<string> LoadAsync(string fileName)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "TestData", fileName);
|
||||
return await File.ReadAllTextAsync(path);
|
||||
}
|
||||
|
||||
private static IAdvisoryDocumentProvider CreateProvider(string key, params AdvisoryDocument[] documents)
|
||||
=> new InMemoryAdvisoryDocumentProvider(new Dictionary<string, IReadOnlyList<AdvisoryDocument>>(StringComparer.Ordinal)
|
||||
{
|
||||
[key] = documents,
|
||||
});
|
||||
|
||||
private sealed class InMemoryAdvisoryDocumentProvider : IAdvisoryDocumentProvider
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<AdvisoryDocument>> _documents;
|
||||
|
||||
public InMemoryAdvisoryDocumentProvider(IReadOnlyDictionary<string, IReadOnlyList<AdvisoryDocument>> documents)
|
||||
{
|
||||
_documents = documents;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_documents.TryGetValue(advisoryKey, out var documents))
|
||||
{
|
||||
return Task.FromResult(documents);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdvisoryDocument>>(Array.Empty<AdvisoryDocument>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Chunking;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
using StellaOps.AdvisoryAI.Vectorization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class AdvisoryVectorRetrieverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SearchAsync_ReturnsBestMatchingChunk()
|
||||
{
|
||||
var advisoryContent = """
|
||||
# Advisory
|
||||
|
||||
## Impact
|
||||
The vulnerability allows remote attackers to execute arbitrary code.
|
||||
|
||||
## Remediation
|
||||
Update to version 2.1.3 or later and restart the service.
|
||||
""";
|
||||
|
||||
var provider = new InMemoryAdvisoryDocumentProvider(new Dictionary<string, IReadOnlyList<AdvisoryDocument>>(StringComparer.Ordinal)
|
||||
{
|
||||
["adv"] = new[]
|
||||
{
|
||||
AdvisoryDocument.Create("VENDOR-1", DocumentFormat.Markdown, "vendor", advisoryContent)
|
||||
}
|
||||
});
|
||||
|
||||
var structuredRetriever = new AdvisoryStructuredRetriever(
|
||||
provider,
|
||||
new IDocumentChunker[]
|
||||
{
|
||||
new CsafDocumentChunker(),
|
||||
new OsvDocumentChunker(),
|
||||
new MarkdownDocumentChunker(),
|
||||
});
|
||||
|
||||
using var encoder = new DeterministicHashVectorEncoder();
|
||||
var vectorRetriever = new AdvisoryVectorRetriever(structuredRetriever, encoder);
|
||||
|
||||
var matches = await vectorRetriever.SearchAsync(
|
||||
new VectorRetrievalRequest(
|
||||
new AdvisoryRetrievalRequest("adv"),
|
||||
Query: "How do I remediate the vulnerability?",
|
||||
TopK: 1),
|
||||
CancellationToken.None);
|
||||
|
||||
matches.Should().HaveCount(1);
|
||||
matches[0].Section().Should().Be("Remediation");
|
||||
}
|
||||
}
|
||||
|
||||
file static class VectorRetrievalMatchExtensions
|
||||
{
|
||||
public static string Section(this VectorRetrievalMatch match)
|
||||
=> match.Metadata.TryGetValue("section", out var value) ? value : string.Empty;
|
||||
}
|
||||
|
||||
file sealed class InMemoryAdvisoryDocumentProvider : IAdvisoryDocumentProvider
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IReadOnlyList<AdvisoryDocument>> _documents;
|
||||
|
||||
public InMemoryAdvisoryDocumentProvider(IReadOnlyDictionary<string, IReadOnlyList<AdvisoryDocument>> documents)
|
||||
{
|
||||
_documents = documents;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_documents.TryGetValue(advisoryKey, out var documents))
|
||||
{
|
||||
return Task.FromResult(documents);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AdvisoryDocument>>(Array.Empty<AdvisoryDocument>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class ConcelierAdvisoryDocumentProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetDocumentsAsync_ReturnsMappedDocuments()
|
||||
{
|
||||
var rawDocument = RawDocumentFactory.CreateAdvisory(
|
||||
tenant: "tenant-a",
|
||||
source: new RawSourceMetadata("vendor-a", "connector", "1.0"),
|
||||
upstream: new RawUpstreamMetadata(
|
||||
"UP-1",
|
||||
"1",
|
||||
DateTimeOffset.UtcNow,
|
||||
"hash-123",
|
||||
new RawSignatureMetadata(false),
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
content: new RawContent("csaf", "2.0", JsonDocument.Parse("{\"document\": {\"notes\": []}, \"vulnerabilities\": []}").RootElement),
|
||||
identifiers: new RawIdentifiers(ImmutableArray<string>.Empty, "UP-1"),
|
||||
linkset: new RawLinkset());
|
||||
|
||||
var records = new[]
|
||||
{
|
||||
new AdvisoryRawRecord("id-1", rawDocument, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)
|
||||
};
|
||||
|
||||
var service = new FakeAdvisoryRawService(records);
|
||||
var provider = new ConcelierAdvisoryDocumentProvider(
|
||||
service,
|
||||
Options.Create(new ConcelierAdvisoryDocumentProviderOptions
|
||||
{
|
||||
Tenant = "tenant-a",
|
||||
MaxDocuments = 5,
|
||||
}),
|
||||
NullLogger<ConcelierAdvisoryDocumentProvider>.Instance);
|
||||
|
||||
var results = await provider.GetDocumentsAsync("UP-1", CancellationToken.None);
|
||||
|
||||
results.Should().HaveCount(1);
|
||||
results[0].Format.Should().Be(Documents.DocumentFormat.Csaf);
|
||||
results[0].Source.Should().Be("vendor-a");
|
||||
}
|
||||
|
||||
private sealed class FakeAdvisoryRawService : IAdvisoryRawService
|
||||
{
|
||||
private readonly IReadOnlyList<AdvisoryRawRecord> _records;
|
||||
|
||||
public FakeAdvisoryRawService(IReadOnlyList<AdvisoryRawRecord> records)
|
||||
{
|
||||
_records = records;
|
||||
}
|
||||
|
||||
public Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<AdvisoryRawRecord?>(null);
|
||||
|
||||
public Task<AdvisoryRawUpsertResult> IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new AdvisoryRawQueryResult(_records, nextCursor: null, hasMore: false));
|
||||
|
||||
public Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class ExcititorVexDocumentProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetDocumentsAsync_ReturnsMappedObservation()
|
||||
{
|
||||
const string vulnerabilityId = "CVE-2024-9999";
|
||||
const string productKey = "product-key";
|
||||
const string packageUrl = "pkg:docker/sample@1.0.0";
|
||||
const string cpe = "cpe:/a:sample:service";
|
||||
const string providerId = "exc-provider";
|
||||
const string tenantId = "tenant-a";
|
||||
|
||||
var observation = CreateObservation(vulnerabilityId, productKey, packageUrl, cpe, providerId, tenantId);
|
||||
var aggregate = new VexObservationAggregate(
|
||||
ImmutableArray.Create(vulnerabilityId),
|
||||
ImmutableArray.Create(productKey),
|
||||
ImmutableArray.Create(packageUrl),
|
||||
ImmutableArray.Create(cpe),
|
||||
ImmutableArray<VexObservationReference>.Empty,
|
||||
ImmutableArray.Create(providerId));
|
||||
|
||||
var queryResult = new VexObservationQueryResult(
|
||||
ImmutableArray.Create(observation),
|
||||
aggregate,
|
||||
NextCursor: null,
|
||||
HasMore: false);
|
||||
|
||||
var service = new FakeVexObservationQueryService(queryResult);
|
||||
var provider = new ExcititorVexDocumentProvider(
|
||||
service,
|
||||
Options.Create(new ExcititorVexDocumentProviderOptions
|
||||
{
|
||||
Tenant = tenantId,
|
||||
MaxObservations = 5,
|
||||
ProviderIds = ImmutableArray.Create(providerId),
|
||||
Statuses = ImmutableArray.Create(VexClaimStatus.NotAffected),
|
||||
}),
|
||||
NullLogger<ExcititorVexDocumentProvider>.Instance);
|
||||
|
||||
var documents = await provider.GetDocumentsAsync(vulnerabilityId, CancellationToken.None);
|
||||
|
||||
documents.Should().HaveCount(1);
|
||||
var document = documents[0];
|
||||
document.DocumentId.Should().Be("obs-1");
|
||||
document.Format.Should().Be(DocumentFormat.OpenVex);
|
||||
document.Source.Should().Be(providerId);
|
||||
document.Metadata.Should().ContainKey("status_counts");
|
||||
document.Metadata["status_counts"].Should().Be("not_affected:1");
|
||||
document.Metadata.Should().ContainKey("aliases");
|
||||
|
||||
service.LastOptions.Should().NotBeNull();
|
||||
service.LastOptions!.Tenant.Should().Be(tenantId);
|
||||
service.LastOptions.ProviderIds.Should().ContainSingle().Which.Should().Be(providerId);
|
||||
service.LastOptions.Statuses.Should().ContainSingle(VexClaimStatus.NotAffected);
|
||||
service.LastOptions.VulnerabilityIds.Should().Contain(vulnerabilityId);
|
||||
service.LastOptions.Limit.Should().Be(5);
|
||||
}
|
||||
|
||||
private static VexObservation CreateObservation(
|
||||
string vulnerabilityId,
|
||||
string productKey,
|
||||
string packageUrl,
|
||||
string cpe,
|
||||
string providerId,
|
||||
string tenantId)
|
||||
{
|
||||
var upstream = new VexObservationUpstream(
|
||||
"VEX-1",
|
||||
1,
|
||||
DateTimeOffset.Parse("2025-10-10T08:00:00Z"),
|
||||
DateTimeOffset.Parse("2025-10-10T08:05:00Z"),
|
||||
"hash-abc123",
|
||||
new VexObservationSignature(true, "dsse", "key-1", "signature"));
|
||||
|
||||
var evidence = ImmutableArray.Create<JsonNode>(JsonNode.Parse("{\"note\":\"deterministic\"}")!);
|
||||
|
||||
var statement = new VexObservationStatement(
|
||||
vulnerabilityId,
|
||||
productKey,
|
||||
VexClaimStatus.NotAffected,
|
||||
DateTimeOffset.Parse("2025-10-10T08:00:00Z"),
|
||||
locator: "selector",
|
||||
justification: VexJustification.ComponentNotPresent,
|
||||
introducedVersion: null,
|
||||
fixedVersion: null,
|
||||
purl: packageUrl,
|
||||
cpe: cpe,
|
||||
evidence: evidence,
|
||||
metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var content = new VexObservationContent(
|
||||
"openvex",
|
||||
"0.2",
|
||||
JsonNode.Parse("{\"statements\":[{\"status\":\"not_affected\"}]}")!);
|
||||
|
||||
var linkset = new VexObservationLinkset(
|
||||
aliases: new[] { vulnerabilityId },
|
||||
purls: new[] { packageUrl },
|
||||
cpes: new[] { cpe },
|
||||
references: null);
|
||||
|
||||
return new VexObservation(
|
||||
"obs-1",
|
||||
tenantId,
|
||||
providerId,
|
||||
"default",
|
||||
upstream,
|
||||
ImmutableArray.Create(statement),
|
||||
content,
|
||||
linkset,
|
||||
DateTimeOffset.Parse("2025-10-11T09:00:00Z"),
|
||||
supersedes: ImmutableArray<string>.Empty,
|
||||
attributes: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
|
||||
private sealed class FakeVexObservationQueryService : IVexObservationQueryService
|
||||
{
|
||||
private readonly VexObservationQueryResult _result;
|
||||
|
||||
public FakeVexObservationQueryService(VexObservationQueryResult result)
|
||||
{
|
||||
_result = result;
|
||||
}
|
||||
|
||||
public VexObservationQueryOptions? LastOptions { get; private set; }
|
||||
|
||||
public ValueTask<VexObservationQueryResult> QueryAsync(
|
||||
VexObservationQueryOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastOptions = options;
|
||||
return ValueTask.FromResult(_result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class SbomContextRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_NormalizesWhitespaceAndLimits()
|
||||
{
|
||||
var request = new SbomContextRequest(
|
||||
artifactId: " scan-42 ",
|
||||
purl: " pkg:docker/sample@1.2.3 ",
|
||||
maxTimelineEntries: 600,
|
||||
maxDependencyPaths: -5,
|
||||
includeEnvironmentFlags: false,
|
||||
includeBlastRadius: false);
|
||||
|
||||
request.ArtifactId.Should().Be("scan-42");
|
||||
request.Purl.Should().Be("pkg:docker/sample@1.2.3");
|
||||
request.MaxTimelineEntries.Should().Be(SbomContextRequest.TimelineLimitCeiling);
|
||||
request.MaxDependencyPaths.Should().Be(0);
|
||||
request.IncludeEnvironmentFlags.Should().BeFalse();
|
||||
request.IncludeBlastRadius.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AllowsNullPurlAndDefaults()
|
||||
{
|
||||
var request = new SbomContextRequest(artifactId: "scan-123", purl: null);
|
||||
|
||||
request.Purl.Should().BeNull();
|
||||
request.MaxTimelineEntries.Should().BeGreaterThan(0);
|
||||
request.MaxDependencyPaths.Should().BeGreaterThan(0);
|
||||
request.IncludeEnvironmentFlags.Should().BeTrue();
|
||||
request.IncludeBlastRadius.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.AdvisoryAI.Retrievers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class SbomContextRetrieverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsDeterministicOrderingAndMetadata()
|
||||
{
|
||||
var document = new SbomContextDocument(
|
||||
"artifact-123",
|
||||
"pkg:docker/sample@1.0.0",
|
||||
ImmutableArray.Create(
|
||||
new SbomVersionRecord(
|
||||
"1.0.1",
|
||||
new DateTimeOffset(2025, 10, 15, 12, 0, 0, TimeSpan.Zero),
|
||||
null,
|
||||
"affected",
|
||||
"scanner",
|
||||
false,
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomVersionRecord(
|
||||
"1.0.0",
|
||||
new DateTimeOffset(2025, 9, 10, 8, 0, 0, TimeSpan.Zero),
|
||||
new DateTimeOffset(2025, 10, 15, 11, 0, 0, TimeSpan.Zero),
|
||||
"fixed",
|
||||
"inventory",
|
||||
true,
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyPathRecord(
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("lib-a", "2.1.3"),
|
||||
new SbomDependencyNodeRecord("lib-b", "3.4.5")),
|
||||
true,
|
||||
"runtime",
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomDependencyPathRecord(
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("test-helper", "0.9.0")),
|
||||
false,
|
||||
"dev",
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomDependencyPathRecord(
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("lib-a", "2.1.3"),
|
||||
new SbomDependencyNodeRecord("lib-b", "3.4.5")),
|
||||
true,
|
||||
"runtime",
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableDictionary.CreateRange(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("environment/prod", "true"),
|
||||
new KeyValuePair<string, string>("environment/dev", "false"),
|
||||
}),
|
||||
new SbomBlastRadiusRecord(
|
||||
12,
|
||||
8,
|
||||
4,
|
||||
0.25,
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var client = new FakeSbomContextClient(document);
|
||||
var retriever = new SbomContextRetriever(client);
|
||||
|
||||
var request = new SbomContextRequest(
|
||||
artifactId: "artifact-123",
|
||||
purl: "pkg:docker/sample@1.0.0",
|
||||
maxTimelineEntries: 2,
|
||||
maxDependencyPaths: 2);
|
||||
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.ArtifactId.Should().Be("artifact-123");
|
||||
result.Purl.Should().Be("pkg:docker/sample@1.0.0");
|
||||
result.VersionTimeline.Select(v => v.Version).Should().ContainInOrder("1.0.0", "1.0.1");
|
||||
result.DependencyPaths.Should().HaveCount(2);
|
||||
result.DependencyPaths.First().IsRuntime.Should().BeTrue();
|
||||
result.DependencyPaths.First().Nodes.Select(n => n.Identifier).Should().Equal("app", "lib-a", "lib-b");
|
||||
result.EnvironmentFlags.Keys.Should().Equal(new[] { "environment/dev", "environment/prod" });
|
||||
result.EnvironmentFlags["environment/prod"].Should().Be("true");
|
||||
result.BlastRadius.Should().NotBeNull();
|
||||
result.BlastRadius!.ImpactedAssets.Should().Be(12);
|
||||
result.Metadata["version_count"].Should().Be("2");
|
||||
result.Metadata["dependency_path_count"].Should().Be("2");
|
||||
result.Metadata["environment_flag_count"].Should().Be("2");
|
||||
result.Metadata["blast_radius_present"].Should().Be(bool.TrueString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_ReturnsEmptyWhenNoDocument()
|
||||
{
|
||||
var client = new FakeSbomContextClient(null);
|
||||
var retriever = new SbomContextRetriever(client);
|
||||
|
||||
var request = new SbomContextRequest("missing-artifact");
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.ArtifactId.Should().Be("missing-artifact");
|
||||
result.VersionTimeline.Should().BeEmpty();
|
||||
result.DependencyPaths.Should().BeEmpty();
|
||||
result.EnvironmentFlags.Should().BeEmpty();
|
||||
result.BlastRadius.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_HonorsEnvironmentFlagToggle()
|
||||
{
|
||||
var document = new SbomContextDocument(
|
||||
"artifact-flag",
|
||||
null,
|
||||
ImmutableArray<SbomVersionRecord>.Empty,
|
||||
ImmutableArray<SbomDependencyPathRecord>.Empty,
|
||||
ImmutableDictionary.CreateRange(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("environment/prod", "true"),
|
||||
}),
|
||||
blastRadius: null,
|
||||
metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var client = new FakeSbomContextClient(document);
|
||||
var retriever = new SbomContextRetriever(client);
|
||||
|
||||
var request = new SbomContextRequest(
|
||||
artifactId: "artifact-flag",
|
||||
includeEnvironmentFlags: false,
|
||||
includeBlastRadius: false);
|
||||
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.EnvironmentFlags.Should().BeEmpty();
|
||||
result.Metadata["environment_flag_count"].Should().Be("0");
|
||||
|
||||
client.LastQuery.Should().NotBeNull();
|
||||
client.LastQuery!.IncludeEnvironmentFlags.Should().BeFalse();
|
||||
client.LastQuery.IncludeBlastRadius.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetrieveAsync_DeduplicatesDependencyPaths()
|
||||
{
|
||||
var duplicatePath = ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("lib-a", "2.0.0"));
|
||||
|
||||
var document = new SbomContextDocument(
|
||||
"artifact-paths",
|
||||
null,
|
||||
ImmutableArray<SbomVersionRecord>.Empty,
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyPathRecord(duplicatePath, true, "runtime", ImmutableDictionary<string, string>.Empty),
|
||||
new SbomDependencyPathRecord(duplicatePath, true, "runtime", ImmutableDictionary<string, string>.Empty),
|
||||
new SbomDependencyPathRecord(
|
||||
ImmutableArray.Create(
|
||||
new SbomDependencyNodeRecord("app", "1.0.0"),
|
||||
new SbomDependencyNodeRecord("dev-tool", "0.1.0")),
|
||||
false,
|
||||
"dev",
|
||||
ImmutableDictionary<string, string>.Empty)),
|
||||
ImmutableDictionary<string, string>.Empty,
|
||||
blastRadius: null,
|
||||
metadata: ImmutableDictionary<string, string>.Empty);
|
||||
|
||||
var client = new FakeSbomContextClient(document);
|
||||
var retriever = new SbomContextRetriever(client);
|
||||
|
||||
var request = new SbomContextRequest(
|
||||
artifactId: "artifact-paths",
|
||||
maxDependencyPaths: 5);
|
||||
|
||||
var result = await retriever.RetrieveAsync(request, CancellationToken.None);
|
||||
|
||||
result.DependencyPaths.Should().HaveCount(2);
|
||||
result.DependencyPaths.First().IsRuntime.Should().BeTrue();
|
||||
result.DependencyPaths.Last().IsRuntime.Should().BeFalse();
|
||||
result.Metadata["dependency_path_count"].Should().Be("2");
|
||||
}
|
||||
|
||||
private sealed class FakeSbomContextClient : ISbomContextClient
|
||||
{
|
||||
private readonly SbomContextDocument? _document;
|
||||
|
||||
public FakeSbomContextClient(SbomContextDocument? document)
|
||||
{
|
||||
_document = document;
|
||||
}
|
||||
|
||||
public SbomContextQuery? LastQuery { get; private set; }
|
||||
|
||||
public Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
LastQuery = query;
|
||||
return Task.FromResult(_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="TestData/*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="TestData/*.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"document": {
|
||||
"tracking": {
|
||||
"id": "CSA-2024-0001"
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"category": "summary",
|
||||
"text": "The vendor has published guidance for CVE-2024-1234."
|
||||
},
|
||||
{
|
||||
"category": "description",
|
||||
"text": "Additional context for operators."
|
||||
},
|
||||
{
|
||||
"category": "other",
|
||||
"text": "This note should be ignored."
|
||||
}
|
||||
]
|
||||
},
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"id": "CVE-2024-1234",
|
||||
"title": "Important vulnerability in component",
|
||||
"description": "Remote attackers may execute arbitrary code.",
|
||||
"notes": [
|
||||
{
|
||||
"category": "description",
|
||||
"text": "Applies to installations using default configuration."
|
||||
}
|
||||
],
|
||||
"remediations": [
|
||||
{
|
||||
"category": "mitigation",
|
||||
"details": "Apply patch level QFE-2024-11 or later.",
|
||||
"product_ids": [
|
||||
"pkg:deb/debian/component@1.2.3"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"openvex": "https://openvex.dev/ns/v0.2",
|
||||
"timestamp": "2025-10-15T12:00:00Z",
|
||||
"version": "1",
|
||||
"statements": [
|
||||
{
|
||||
"vulnerability": "CVE-2024-9999",
|
||||
"products": [
|
||||
{
|
||||
"product_id": "pkg:docker/sample@1.0.0"
|
||||
}
|
||||
],
|
||||
"status": "not_affected",
|
||||
"justification": "component_not_present",
|
||||
"impact_statement": "Component not shipped",
|
||||
"status_notes": "Distribution excludes this component",
|
||||
"timestamp": "2025-10-10T08:00:00Z",
|
||||
"last_updated": "2025-10-11T09:00:00Z"
|
||||
},
|
||||
{
|
||||
"vulnerability": "CVE-2024-8888",
|
||||
"products": [
|
||||
"component://sample/service"
|
||||
],
|
||||
"status": "affected",
|
||||
"status_notes": "Patch scheduled",
|
||||
"timestamp": "2025-10-12T13:30:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "OSV-2024-0001",
|
||||
"summary": "Vulnerability in package affects multiple versions.",
|
||||
"details": "Remote attackers may exploit the issue under specific conditions.",
|
||||
"affected": [
|
||||
{
|
||||
"package": {
|
||||
"name": "example",
|
||||
"ecosystem": "npm"
|
||||
},
|
||||
"ranges": [
|
||||
{
|
||||
"type": "SEMVER",
|
||||
"events": [
|
||||
{
|
||||
"introduced": "0"
|
||||
},
|
||||
{
|
||||
"fixed": "1.2.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"versions": ["0.9.0", "1.0.0"]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"type": "ADVISORY",
|
||||
"url": "https://example.org/advisory"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Vendor Advisory 2024-01
|
||||
|
||||
Initial notice describing the vulnerability and affected platforms.
|
||||
|
||||
## Impact
|
||||
End-users may experience remote compromise when default credentials are unchanged.
|
||||
|
||||
## Remediation
|
||||
Apply hotfix package 2024.11.5 and rotate secrets within 24 hours.
|
||||
|
||||
## References
|
||||
- https://vendor.example.com/advisories/2024-01
|
||||
Reference in New Issue
Block a user