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:
master
2025-11-03 01:13:21 +02:00
parent 1d962ee6fc
commit ff0eca3a51
67 changed files with 5198 additions and 214 deletions

View File

@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.AdvisoryAI.Context;
namespace StellaOps.AdvisoryAI.Abstractions;
public interface ISbomContextRetriever
{
Task<SbomContextResult> RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,82 @@
using System;
namespace StellaOps.AdvisoryAI.Abstractions;
/// <summary>
/// Defines the inputs required to build SBOM-derived context for Advisory AI prompts.
/// </summary>
public sealed class SbomContextRequest
{
/// <summary>
/// Maximum number of version timeline entries we will ever request from the SBOM service.
/// </summary>
public const int TimelineLimitCeiling = 500;
/// <summary>
/// Maximum number of dependency paths we will ever request from the SBOM service.
/// </summary>
public const int DependencyPathLimitCeiling = 200;
public SbomContextRequest(
string artifactId,
string? purl = null,
int maxTimelineEntries = 50,
int maxDependencyPaths = 25,
bool includeEnvironmentFlags = true,
bool includeBlastRadius = true)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
ArtifactId = artifactId.Trim();
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
MaxTimelineEntries = NormalizeLimit(maxTimelineEntries, TimelineLimitCeiling);
MaxDependencyPaths = NormalizeLimit(maxDependencyPaths, DependencyPathLimitCeiling);
IncludeEnvironmentFlags = includeEnvironmentFlags;
IncludeBlastRadius = includeBlastRadius;
}
/// <summary>
/// The advisory artifact identifier (e.g. internal scan ID).
/// </summary>
public string ArtifactId { get; }
/// <summary>
/// Optional package URL used to scope SBOM data.
/// </summary>
public string? Purl { get; }
/// <summary>
/// Maximum number of timeline entries that should be returned. Set to 0 to disable.
/// </summary>
public int MaxTimelineEntries { get; }
/// <summary>
/// Maximum number of dependency paths that should be returned. Set to 0 to disable.
/// </summary>
public int MaxDependencyPaths { get; }
/// <summary>
/// Whether environment feature flags (prod/staging/etc.) should be returned.
/// </summary>
public bool IncludeEnvironmentFlags { get; }
/// <summary>
/// Whether blast radius summaries should be returned.
/// </summary>
public bool IncludeBlastRadius { get; }
private static int NormalizeLimit(int requested, int ceiling)
{
if (requested <= 0)
{
return 0;
}
if (requested > ceiling)
{
return ceiling;
}
return requested;
}
}

View File

@@ -0,0 +1,278 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Chunking;
internal sealed class CsafDocumentChunker : IDocumentChunker
{
private static readonly ImmutableHashSet<string> SupportedNoteCategories =
ImmutableHashSet.Create(StringComparer.OrdinalIgnoreCase, "summary", "description", "remediation");
public bool CanHandle(DocumentFormat format) => format == DocumentFormat.Csaf;
public IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document)
{
ArgumentNullException.ThrowIfNull(document);
using var jsonDocument = JsonDocument.Parse(document.Content);
var root = jsonDocument.RootElement;
var chunkIndex = 0;
var sectionStack = new Stack<string>();
sectionStack.Push("document");
string NextChunkId() => $"{document.DocumentId}:{++chunkIndex:D4}";
foreach (var chunk in ExtractDocumentNotes(document, root, NextChunkId))
{
yield return chunk;
}
foreach (var chunk in ExtractVulnerabilities(document, root, NextChunkId))
{
yield return chunk;
}
}
private static IEnumerable<AdvisoryChunk> ExtractDocumentNotes(
AdvisoryDocument document,
JsonElement root,
Func<string> chunkIdFactory)
{
if (!root.TryGetProperty("document", out var documentNode))
{
yield break;
}
if (!documentNode.TryGetProperty("notes", out var notesNode) || notesNode.ValueKind != JsonValueKind.Array)
{
yield break;
}
var index = 0;
foreach (var note in notesNode.EnumerateArray())
{
index++;
if (note.ValueKind != JsonValueKind.Object)
{
continue;
}
var text = note.GetPropertyOrDefault("text");
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
var category = note.GetPropertyOrDefault("category");
if (category.Length > 0 && !SupportedNoteCategories.Contains(category))
{
continue;
}
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["format"] = "csaf",
["section"] = "document.notes",
["index"] = index.ToString(),
};
if (category.Length > 0)
{
metadata["category"] = category;
}
yield return AdvisoryChunk.Create(
document.DocumentId,
chunkIdFactory(),
section: "document.notes",
paragraphId: $"document.notes[{index}]",
text: text.Trim(),
metadata: metadata);
}
}
private static IEnumerable<AdvisoryChunk> ExtractVulnerabilities(
AdvisoryDocument document,
JsonElement root,
Func<string> chunkIdFactory)
{
if (!root.TryGetProperty("vulnerabilities", out var vulnerabilitiesNode) ||
vulnerabilitiesNode.ValueKind != JsonValueKind.Array)
{
yield break;
}
var vulnIndex = 0;
foreach (var vulnerability in vulnerabilitiesNode.EnumerateArray())
{
vulnIndex++;
if (vulnerability.ValueKind != JsonValueKind.Object)
{
continue;
}
var vulnerabilityId = vulnerability.GetPropertyOrDefault("id", fallback: vulnIndex.ToString());
foreach (var chunk in ExtractVulnerabilityTitle(document, vulnerability, vulnerabilityId, chunkIdFactory))
{
yield return chunk;
}
foreach (var chunk in ExtractVulnerabilityNotes(document, vulnerability, vulnerabilityId, chunkIdFactory))
{
yield return chunk;
}
foreach (var chunk in ExtractRemediations(document, vulnerability, vulnerabilityId, chunkIdFactory))
{
yield return chunk;
}
}
}
private static IEnumerable<AdvisoryChunk> ExtractVulnerabilityTitle(
AdvisoryDocument document,
JsonElement vulnerability,
string vulnerabilityId,
Func<string> chunkIdFactory)
{
var title = vulnerability.GetPropertyOrDefault("title");
var description = vulnerability.GetPropertyOrDefault("description");
if (!string.IsNullOrWhiteSpace(title))
{
yield return CreateChunk(document, chunkIdFactory(), "vulnerabilities.title", vulnerabilityId, title!);
}
if (!string.IsNullOrWhiteSpace(description))
{
yield return CreateChunk(document, chunkIdFactory(), "vulnerabilities.description", vulnerabilityId, description!);
}
}
private static IEnumerable<AdvisoryChunk> ExtractVulnerabilityNotes(
AdvisoryDocument document,
JsonElement vulnerability,
string vulnerabilityId,
Func<string> chunkIdFactory)
{
if (!vulnerability.TryGetProperty("notes", out var notes) || notes.ValueKind != JsonValueKind.Array)
{
yield break;
}
var noteIndex = 0;
foreach (var note in notes.EnumerateArray())
{
noteIndex++;
if (note.ValueKind != JsonValueKind.Object)
{
continue;
}
var text = note.GetPropertyOrDefault("text");
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
yield return CreateChunk(
document,
chunkIdFactory(),
"vulnerabilities.notes",
vulnerabilityId,
text!,
additionalMetadata: new Dictionary<string, string>
{
["noteIndex"] = noteIndex.ToString(),
});
}
}
private static IEnumerable<AdvisoryChunk> ExtractRemediations(
AdvisoryDocument document,
JsonElement vulnerability,
string vulnerabilityId,
Func<string> chunkIdFactory)
{
if (!vulnerability.TryGetProperty("remediations", out var remediations) || remediations.ValueKind != JsonValueKind.Array)
{
yield break;
}
var remediationIndex = 0;
foreach (var remediation in remediations.EnumerateArray())
{
remediationIndex++;
if (remediation.ValueKind != JsonValueKind.Object)
{
continue;
}
var details = remediation.GetPropertyOrDefault("details");
if (string.IsNullOrWhiteSpace(details))
{
continue;
}
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["remediationIndex"] = remediationIndex.ToString(),
};
var type = remediation.GetPropertyOrDefault("category");
if (!string.IsNullOrWhiteSpace(type))
{
metadata["category"] = type!;
}
var productIds = remediation.GetPropertyOrDefault("product_ids");
if (!string.IsNullOrWhiteSpace(productIds))
{
metadata["product_ids"] = productIds!;
}
yield return CreateChunk(
document,
chunkIdFactory(),
"vulnerabilities.remediations",
vulnerabilityId,
details!,
metadata);
}
}
private static AdvisoryChunk CreateChunk(
AdvisoryDocument document,
string chunkId,
string section,
string paragraph,
string text,
IReadOnlyDictionary<string, string>? additionalMetadata = null)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["format"] = "csaf",
["section"] = section,
["paragraph"] = paragraph,
};
if (additionalMetadata is not null)
{
foreach (var pair in additionalMetadata)
{
metadata[pair.Key] = pair.Value;
}
}
return AdvisoryChunk.Create(
document.DocumentId,
chunkId,
section,
paragraph,
text.Trim(),
metadata);
}
}

View File

@@ -0,0 +1,41 @@
using System.Linq;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Chunking;
internal static class JsonElementExtensions
{
public static string GetPropertyOrDefault(this JsonElement element, string propertyName, string? fallback = "")
{
if (element.ValueKind != JsonValueKind.Object)
{
return fallback ?? string.Empty;
}
if (element.TryGetProperty(propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.String => property.GetString() ?? string.Empty,
JsonValueKind.Array => string.Join(",", property.EnumerateArray().Select(ToStringValue).Where(static v => !string.IsNullOrWhiteSpace(v))),
JsonValueKind.Object => property.ToString(),
JsonValueKind.Number => property.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => string.Empty,
};
}
return fallback ?? string.Empty;
}
private static string? ToStringValue(JsonElement element)
=> element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.ToString(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null,
};
}

View File

@@ -0,0 +1,86 @@
using System.Text.RegularExpressions;
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Chunking;
internal sealed class MarkdownDocumentChunker : IDocumentChunker
{
private static readonly Regex HeadingRegex = new("^(#+)\\s+(?<title>.+)$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public bool CanHandle(DocumentFormat format) => format == DocumentFormat.Markdown;
public IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document)
{
var lines = document.Content.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
var section = "body";
var paragraphId = 0;
var chunkIndex = 0;
var buffer = new List<string>();
IEnumerable<AdvisoryChunk> FlushBuffer()
{
if (buffer.Count == 0)
{
yield break;
}
var text = string.Join("\n", buffer).Trim();
buffer.Clear();
if (string.IsNullOrWhiteSpace(text))
{
yield break;
}
paragraphId++;
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["format"] = "markdown",
["section"] = section,
["paragraph"] = paragraphId.ToString(),
};
yield return AdvisoryChunk.Create(
document.DocumentId,
chunkId: $"{document.DocumentId}:{++chunkIndex:D4}",
section,
paragraphId: $"{section}#{paragraphId}",
text,
metadata);
}
foreach (var rawLine in lines)
{
var line = rawLine.TrimEnd();
if (line.Length == 0)
{
foreach (var chunk in FlushBuffer())
{
yield return chunk;
}
continue;
}
var headingMatch = HeadingRegex.Match(line);
if (headingMatch.Success)
{
foreach (var chunk in FlushBuffer())
{
yield return chunk;
}
var level = headingMatch.Groups[1].Value.Length;
var title = headingMatch.Groups["title"].Value.Trim();
section = level == 1 ? title : $"{section}/{title}";
paragraphId = 0;
continue;
}
buffer.Add(line);
}
foreach (var chunk in FlushBuffer())
{
yield return chunk;
}
}
}

View File

@@ -0,0 +1,199 @@
using System.Text;
using System.Text.Json;
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Chunking;
internal sealed class OpenVexDocumentChunker : IDocumentChunker
{
public bool CanHandle(DocumentFormat format) => format == DocumentFormat.OpenVex;
public IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document)
{
ArgumentNullException.ThrowIfNull(document);
using var jsonDocument = JsonDocument.Parse(document.Content);
var root = jsonDocument.RootElement;
if (!root.TryGetProperty("statements", out var statements) || statements.ValueKind != JsonValueKind.Array)
{
yield break;
}
var index = 0;
foreach (var statement in statements.EnumerateArray())
{
index++;
if (statement.ValueKind != JsonValueKind.Object)
{
continue;
}
var vulnerabilityId = statement.GetPropertyOrDefault("vulnerability", fallback: string.Empty);
var status = statement.GetPropertyOrDefault("status", fallback: string.Empty);
var justification = statement.GetPropertyOrDefault("justification", fallback: string.Empty);
var impact = statement.GetPropertyOrDefault("impact_statement", fallback: string.Empty);
var notes = statement.GetPropertyOrDefault("status_notes", fallback: string.Empty);
var timestamp = statement.GetPropertyOrDefault("timestamp", fallback: string.Empty);
var lastUpdated = statement.GetPropertyOrDefault("last_updated", fallback: string.Empty);
var products = ExtractProducts(statement);
var section = "vex.statements";
var paragraphId = $"statements[{index}]";
var chunkId = $"{document.DocumentId}:{index:D4}";
var text = BuildStatementSummary(vulnerabilityId, status, justification, impact, notes, products);
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["format"] = "openvex",
["section"] = section,
};
if (!string.IsNullOrWhiteSpace(vulnerabilityId))
{
metadata["vulnerability"] = vulnerabilityId;
}
if (!string.IsNullOrWhiteSpace(status))
{
metadata["status"] = status;
}
if (!string.IsNullOrWhiteSpace(justification))
{
metadata["justification"] = justification;
}
if (!string.IsNullOrWhiteSpace(impact))
{
metadata["impact_statement"] = impact;
}
if (!string.IsNullOrWhiteSpace(notes))
{
metadata["status_notes"] = notes;
}
if (products.Count > 0)
{
metadata["products"] = string.Join(",", products);
}
if (!string.IsNullOrWhiteSpace(timestamp))
{
metadata["timestamp"] = timestamp;
}
if (!string.IsNullOrWhiteSpace(lastUpdated))
{
metadata["last_updated"] = lastUpdated;
}
yield return AdvisoryChunk.Create(
document.DocumentId,
chunkId,
section,
paragraphId,
text,
metadata);
}
}
private static List<string> ExtractProducts(JsonElement statement)
{
if (!statement.TryGetProperty("products", out var productsElement) ||
productsElement.ValueKind != JsonValueKind.Array)
{
return new List<string>();
}
var results = new List<string>();
foreach (var product in productsElement.EnumerateArray())
{
switch (product.ValueKind)
{
case JsonValueKind.String:
var value = product.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
results.Add(value.Trim());
}
break;
case JsonValueKind.Object:
var productId = product.GetPropertyOrDefault("product_id", fallback: string.Empty);
if (!string.IsNullOrWhiteSpace(productId))
{
results.Add(productId);
break;
}
var name = product.GetPropertyOrDefault("name", fallback: string.Empty);
if (!string.IsNullOrWhiteSpace(name))
{
results.Add(name);
}
break;
default:
continue;
}
}
return results;
}
private static string BuildStatementSummary(
string vulnerabilityId,
string status,
string justification,
string impact,
string notes,
IReadOnlyList<string> products)
{
var builder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(vulnerabilityId))
{
builder.Append(vulnerabilityId.Trim());
}
else
{
builder.Append("Unknown vulnerability");
}
if (products.Count > 0)
{
builder.Append(" affects ");
builder.Append(string.Join(", ", products));
}
if (!string.IsNullOrWhiteSpace(status))
{
builder.Append(" → status: ");
builder.Append(status.Trim());
}
if (!string.IsNullOrWhiteSpace(justification))
{
builder.Append(" (justification: ");
builder.Append(justification.Trim());
builder.Append(')');
}
if (!string.IsNullOrWhiteSpace(impact))
{
builder.Append(". Impact: ");
builder.Append(impact.Trim());
}
if (!string.IsNullOrWhiteSpace(notes))
{
builder.Append(". Notes: ");
builder.Append(notes.Trim());
}
return builder.ToString();
}
}

View File

@@ -0,0 +1,138 @@
using System.Text.Json;
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Chunking;
internal sealed class OsvDocumentChunker : IDocumentChunker
{
public bool CanHandle(DocumentFormat format) => format == DocumentFormat.Osv;
public IEnumerable<AdvisoryChunk> Chunk(AdvisoryDocument document)
{
using var jsonDocument = JsonDocument.Parse(document.Content);
var root = jsonDocument.RootElement;
var chunkIndex = 0;
string NextChunkId() => $"{document.DocumentId}:{++chunkIndex:D4}";
string summary = root.GetPropertyOrDefault("summary");
if (!string.IsNullOrWhiteSpace(summary))
{
yield return CreateChunk(document, NextChunkId(), "summary", "summary", summary!);
}
string details = root.GetPropertyOrDefault("details");
if (!string.IsNullOrWhiteSpace(details))
{
yield return CreateChunk(document, NextChunkId(), "details", "details", details!);
}
if (root.TryGetProperty("affected", out var affectedNode) && affectedNode.ValueKind == JsonValueKind.Array)
{
var affectedIndex = 0;
foreach (var affected in affectedNode.EnumerateArray())
{
affectedIndex++;
if (affected.ValueKind != JsonValueKind.Object)
{
continue;
}
var packageName = string.Empty;
var ecosystem = string.Empty;
if (affected.TryGetProperty("package", out var package))
{
packageName = package.GetPropertyOrDefault("name", string.Empty);
ecosystem = package.GetPropertyOrDefault("ecosystem", string.Empty);
}
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["package"] = packageName,
["ecosystem"] = ecosystem,
};
if (affected.TryGetProperty("ranges", out var ranges) && ranges.ValueKind == JsonValueKind.Array)
{
var rangeIndex = 0;
foreach (var range in ranges.EnumerateArray())
{
rangeIndex++;
var events = range.GetPropertyOrDefault("events");
if (string.IsNullOrWhiteSpace(events))
{
continue;
}
var rangeMetadata = new Dictionary<string, string>(metadata)
{
["range.type"] = range.GetPropertyOrDefault("type", fallback: ""),
["range.index"] = rangeIndex.ToString(),
};
yield return CreateChunk(
document,
NextChunkId(),
"affected.ranges",
$"affected[{affectedIndex}]",
events!,
metadata: rangeMetadata);
}
}
var versions = affected.GetPropertyOrDefault("versions");
if (!string.IsNullOrWhiteSpace(versions))
{
var versionMetadata = new Dictionary<string, string>(metadata)
{
["section"] = "affected.versions",
};
yield return CreateChunk(
document,
NextChunkId(),
"affected.versions",
$"affected[{affectedIndex}]",
versions!,
metadata: versionMetadata);
}
}
}
var references = root.GetPropertyOrDefault("references");
if (!string.IsNullOrWhiteSpace(references))
{
yield return CreateChunk(document, NextChunkId(), "references", "references", references!);
}
}
private static AdvisoryChunk CreateChunk(
AdvisoryDocument document,
string chunkId,
string section,
string paragraph,
string text,
IReadOnlyDictionary<string, string>? metadata = null)
{
var meta = new Dictionary<string, string>(StringComparer.Ordinal)
{
["format"] = "osv",
["section"] = section,
["paragraph"] = paragraph,
};
if (metadata is not null)
{
foreach (var pair in metadata)
{
meta[pair.Key] = pair.Value;
}
}
return AdvisoryChunk.Create(
document.DocumentId,
chunkId,
section,
paragraph,
text.Trim(),
meta);
}
}

View File

@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.AdvisoryAI.Context;
/// <summary>
/// Represents SBOM-derived context that Advisory AI can hydrate into prompts.
/// </summary>
public sealed class SbomContextResult
{
private SbomContextResult(
string artifactId,
string? purl,
ImmutableArray<SbomVersionTimelineEntry> versionTimeline,
ImmutableArray<SbomDependencyPath> dependencyPaths,
ImmutableDictionary<string, string> environmentFlags,
SbomBlastRadiusSummary? blastRadius,
ImmutableDictionary<string, string> metadata)
{
ArtifactId = artifactId;
Purl = purl;
VersionTimeline = versionTimeline;
DependencyPaths = dependencyPaths;
EnvironmentFlags = environmentFlags;
BlastRadius = blastRadius;
Metadata = metadata;
}
public string ArtifactId { get; }
public string? Purl { get; }
public ImmutableArray<SbomVersionTimelineEntry> VersionTimeline { get; }
public ImmutableArray<SbomDependencyPath> DependencyPaths { get; }
public ImmutableDictionary<string, string> EnvironmentFlags { get; }
public SbomBlastRadiusSummary? BlastRadius { get; }
public ImmutableDictionary<string, string> Metadata { get; }
public static SbomContextResult Create(
string artifactId,
string? purl,
IEnumerable<SbomVersionTimelineEntry> versionTimeline,
IEnumerable<SbomDependencyPath> dependencyPaths,
IReadOnlyDictionary<string, string>? environmentFlags = null,
SbomBlastRadiusSummary? blastRadius = null,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
ArgumentNullException.ThrowIfNull(versionTimeline);
ArgumentNullException.ThrowIfNull(dependencyPaths);
var timeline = versionTimeline.ToImmutableArray();
var paths = dependencyPaths.ToImmutableArray();
var flags = environmentFlags is null
? ImmutableDictionary<string, string>.Empty
: environmentFlags.ToImmutableDictionary(StringComparer.Ordinal);
var meta = metadata is null
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.Ordinal);
return new SbomContextResult(
artifactId.Trim(),
string.IsNullOrWhiteSpace(purl) ? null : purl.Trim(),
timeline,
paths,
flags,
blastRadius,
meta);
}
public static SbomContextResult Empty(string artifactId, string? purl = null)
=> Create(artifactId, purl, Array.Empty<SbomVersionTimelineEntry>(), Array.Empty<SbomDependencyPath>());
}
public sealed class SbomVersionTimelineEntry
{
public SbomVersionTimelineEntry(
string version,
DateTimeOffset firstObserved,
DateTimeOffset? lastObserved,
string status,
string source)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
ArgumentException.ThrowIfNullOrWhiteSpace(status);
ArgumentException.ThrowIfNullOrWhiteSpace(source);
Version = version.Trim();
FirstObserved = firstObserved;
LastObserved = lastObserved;
Status = status.Trim();
Source = source.Trim();
}
public string Version { get; }
public DateTimeOffset FirstObserved { get; }
public DateTimeOffset? LastObserved { get; }
public string Status { get; }
public string Source { get; }
}
public sealed class SbomDependencyPath
{
public SbomDependencyPath(
IEnumerable<SbomDependencyNode> nodes,
bool isRuntime,
string? source = null,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentNullException.ThrowIfNull(nodes);
var immutableNodes = nodes.ToImmutableArray();
if (immutableNodes.IsDefaultOrEmpty)
{
throw new ArgumentException("At least one node must be supplied.", nameof(nodes));
}
Nodes = immutableNodes;
IsRuntime = isRuntime;
Source = string.IsNullOrWhiteSpace(source) ? null : source.Trim();
Metadata = metadata is null
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.Ordinal);
}
public ImmutableArray<SbomDependencyNode> Nodes { get; }
public bool IsRuntime { get; }
public string? Source { get; }
public ImmutableDictionary<string, string> Metadata { get; }
}
public sealed class SbomDependencyNode
{
public SbomDependencyNode(string identifier, string? version)
{
ArgumentException.ThrowIfNullOrWhiteSpace(identifier);
Identifier = identifier.Trim();
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
}
public string Identifier { get; }
public string? Version { get; }
}
public sealed class SbomBlastRadiusSummary
{
public SbomBlastRadiusSummary(
int impactedAssets,
int impactedWorkloads,
int impactedNamespaces,
double? impactedPercentage,
IReadOnlyDictionary<string, string>? metadata = null)
{
ImpactedAssets = Math.Max(0, impactedAssets);
ImpactedWorkloads = Math.Max(0, impactedWorkloads);
ImpactedNamespaces = Math.Max(0, impactedNamespaces);
ImpactedPercentage = impactedPercentage.HasValue
? Math.Max(0, impactedPercentage.Value)
: null;
Metadata = metadata is null
? ImmutableDictionary<string, string>.Empty
: metadata.ToImmutableDictionary(StringComparer.Ordinal);
}
public int ImpactedAssets { get; }
public int ImpactedWorkloads { get; }
public int ImpactedNamespaces { get; }
public double? ImpactedPercentage { get; }
public ImmutableDictionary<string, string> Metadata { get; }
}

View File

@@ -6,4 +6,5 @@ public enum DocumentFormat
Csaf,
Osv,
Markdown,
OpenVex,
}

View File

@@ -0,0 +1,24 @@
using System.Globalization;
namespace StellaOps.AdvisoryAI.Documents;
internal static class DocumentFormatMapper
{
public static DocumentFormat Map(string? format)
{
if (string.IsNullOrWhiteSpace(format))
{
return DocumentFormat.Unknown;
}
var normalized = format.Trim().ToLowerInvariant();
return normalized switch
{
"csaf" or "csaf-json" or "csaf_json" or "csaf/v2" => DocumentFormat.Csaf,
"osv" => DocumentFormat.Osv,
"markdown" or "md" => DocumentFormat.Markdown,
"openvex" or "open-vex" or "vex" => DocumentFormat.OpenVex,
_ => DocumentFormat.Unknown,
};
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.AdvisoryAI.Tests")]

View File

@@ -0,0 +1,124 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.Concelier.Core.Raw;
namespace StellaOps.AdvisoryAI.Providers;
public sealed class ConcelierAdvisoryDocumentProviderOptions
{
public string Tenant { get; set; } = string.Empty;
public ImmutableArray<string> Vendors { get; set; } = ImmutableArray<string>.Empty;
public int MaxDocuments { get; set; } = 25;
}
internal sealed class ConcelierAdvisoryDocumentProvider : IAdvisoryDocumentProvider
{
private readonly IAdvisoryRawService _rawService;
private readonly ConcelierAdvisoryDocumentProviderOptions _options;
private readonly ILogger<ConcelierAdvisoryDocumentProvider>? _logger;
public ConcelierAdvisoryDocumentProvider(
IAdvisoryRawService rawService,
IOptions<ConcelierAdvisoryDocumentProviderOptions> options,
ILogger<ConcelierAdvisoryDocumentProvider>? logger = null)
{
_rawService = rawService ?? throw new ArgumentNullException(nameof(rawService));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
if (string.IsNullOrWhiteSpace(_options.Tenant))
{
throw new ArgumentException("Tenant must be configured.", nameof(options));
}
if (_options.MaxDocuments <= 0)
{
throw new ArgumentOutOfRangeException(nameof(options), "MaxDocuments must be positive.");
}
}
public async Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
var options = new AdvisoryRawQueryOptions(_options.Tenant)
{
Aliases = ImmutableArray.Create(advisoryKey),
UpstreamIds = ImmutableArray.Create(advisoryKey),
Vendors = _options.Vendors,
Limit = _options.MaxDocuments
};
var result = await _rawService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
if (result.Records.Count == 0)
{
_logger?.LogDebug("No advisory raw records returned for key {AdvisoryKey}", advisoryKey);
return Array.Empty<AdvisoryDocument>();
}
var documents = new List<AdvisoryDocument>(result.Records.Count);
foreach (var record in result.Records)
{
var raw = record.Document.Content;
var format = DocumentFormatMapper.Map(raw.Format);
if (format == DocumentFormat.Unknown)
{
_logger?.LogWarning("Unsupported advisory content format {Format} for advisory {AdvisoryKey}", raw.Format, advisoryKey);
continue;
}
var documentId = DetermineDocumentId(record);
var metadata = BuildMetadata(record);
var json = record.Document.Content.Raw.GetRawText();
documents.Add(AdvisoryDocument.Create(documentId, format, record.Document.Source.Vendor, json, metadata));
}
return documents;
}
private static string DetermineDocumentId(AdvisoryRawRecord record)
{
if (!string.IsNullOrWhiteSpace(record.Document.Upstream.UpstreamId))
{
return record.Document.Upstream.UpstreamId;
}
var primary = record.Document.Identifiers.PrimaryId;
if (!string.IsNullOrWhiteSpace(primary))
{
return primary;
}
return record.Id;
}
private static IReadOnlyDictionary<string, string> BuildMetadata(AdvisoryRawRecord record)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["tenant"] = record.Document.Tenant,
["vendor"] = record.Document.Source.Vendor,
["connector"] = record.Document.Source.Connector,
["content_hash"] = record.Document.Upstream.ContentHash,
["ingested_at"] = record.IngestedAt.UtcDateTime.ToString("O"),
};
if (!string.IsNullOrWhiteSpace(record.Document.Source.Stream))
{
metadata["stream"] = record.Document.Source.Stream!;
}
if (!string.IsNullOrWhiteSpace(record.Document.Upstream.DocumentVersion))
{
metadata["document_version"] = record.Document.Upstream.DocumentVersion!;
}
return metadata;
}
}

View File

@@ -0,0 +1,202 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Observations;
namespace StellaOps.AdvisoryAI.Providers;
public sealed class ExcititorVexDocumentProviderOptions
{
public string Tenant { get; set; } = string.Empty;
public ImmutableArray<string> ProviderIds { get; set; } = ImmutableArray<string>.Empty;
public ImmutableArray<VexClaimStatus> Statuses { get; set; } = ImmutableArray<VexClaimStatus>.Empty;
public int MaxObservations { get; set; } = 25;
}
internal sealed class ExcititorVexDocumentProvider : IAdvisoryDocumentProvider
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
};
private readonly IVexObservationQueryService _queryService;
private readonly ExcititorVexDocumentProviderOptions _options;
private readonly ILogger<ExcititorVexDocumentProvider>? _logger;
public ExcititorVexDocumentProvider(
IVexObservationQueryService queryService,
IOptions<ExcititorVexDocumentProviderOptions> options,
ILogger<ExcititorVexDocumentProvider>? logger = null)
{
_queryService = queryService ?? throw new ArgumentNullException(nameof(queryService));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger;
if (string.IsNullOrWhiteSpace(_options.Tenant))
{
throw new ArgumentException("Tenant must be configured.", nameof(options));
}
if (_options.MaxObservations <= 0)
{
throw new ArgumentOutOfRangeException(nameof(options), "MaxObservations must be positive.");
}
}
public async Task<IReadOnlyList<AdvisoryDocument>> GetDocumentsAsync(string advisoryKey, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
var normalizedKey = advisoryKey.Trim();
var lookup = ImmutableArray.Create(normalizedKey);
var providerIds = _options.ProviderIds.IsDefaultOrEmpty
? ImmutableArray<string>.Empty
: _options.ProviderIds;
var statuses = _options.Statuses.IsDefaultOrEmpty
? ImmutableArray<VexClaimStatus>.Empty
: _options.Statuses;
var options = new VexObservationQueryOptions(
_options.Tenant,
observationIds: lookup,
vulnerabilityIds: lookup,
productKeys: lookup,
purls: ImmutableArray<string>.Empty,
cpes: ImmutableArray<string>.Empty,
providerIds: providerIds,
statuses: statuses,
limit: _options.MaxObservations);
var result = await _queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
if (result.Observations.IsDefaultOrEmpty)
{
_logger?.LogDebug("No VEX observations returned for advisory key {AdvisoryKey}", normalizedKey);
return Array.Empty<AdvisoryDocument>();
}
var documents = new List<AdvisoryDocument>(result.Observations.Length);
foreach (var observation in result.Observations)
{
var format = DocumentFormatMapper.Map(observation.Content.Format);
if (format == DocumentFormat.Unknown)
{
_logger?.LogWarning(
"Unsupported VEX content format {Format} for observation {ObservationId}",
observation.Content.Format,
observation.ObservationId);
continue;
}
var content = observation.Content.Raw.ToJsonString(JsonOptions);
var metadata = BuildMetadata(observation);
documents.Add(AdvisoryDocument.Create(
observation.ObservationId,
format,
observation.ProviderId,
content,
metadata));
}
return documents;
}
private static IReadOnlyDictionary<string, string> BuildMetadata(VexObservation observation)
{
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["tenant"] = observation.Tenant,
["provider"] = observation.ProviderId,
["stream"] = observation.StreamId,
["created_at"] = observation.CreatedAt.ToString("O", CultureInfo.InvariantCulture),
["statement_count"] = observation.Statements.Length.ToString(CultureInfo.InvariantCulture),
["content_format"] = observation.Content.Format,
["content_hash"] = observation.Upstream.ContentHash,
};
if (!string.IsNullOrWhiteSpace(observation.Content.SpecVersion))
{
metadata["spec_version"] = observation.Content.SpecVersion!;
}
if (!string.IsNullOrWhiteSpace(observation.Upstream.DocumentVersion))
{
metadata["document_version"] = observation.Upstream.DocumentVersion!;
}
if (observation.Supersedes.Length > 0)
{
metadata["supersedes"] = string.Join(",", observation.Supersedes);
}
if (observation.Linkset.Aliases.Length > 0)
{
metadata["aliases"] = string.Join(",", observation.Linkset.Aliases);
}
if (observation.Linkset.Purls.Length > 0)
{
metadata["purls"] = string.Join(",", observation.Linkset.Purls);
}
if (observation.Linkset.Cpes.Length > 0)
{
metadata["cpes"] = string.Join(",", observation.Linkset.Cpes);
}
var statusSummary = BuildStatusSummary(observation.Statements);
if (statusSummary.Length > 0)
{
metadata["status_counts"] = statusSummary;
}
return metadata;
}
private static string BuildStatusSummary(ImmutableArray<VexObservationStatement> statements)
{
if (statements.IsDefaultOrEmpty)
{
return string.Empty;
}
var counts = new SortedDictionary<string, int>(StringComparer.Ordinal);
foreach (var statement in statements)
{
var key = ToStatusKey(statement.Status);
counts.TryGetValue(key, out var current);
counts[key] = current + 1;
}
if (counts.Count == 0)
{
return string.Empty;
}
return string.Join(
';',
counts.Select(pair => $"{pair.Key}:{pair.Value.ToString(CultureInfo.InvariantCulture)}"));
}
private static string ToStatusKey(VexClaimStatus status)
=> status switch
{
VexClaimStatus.Affected => "affected",
VexClaimStatus.NotAffected => "not_affected",
VexClaimStatus.Fixed => "fixed",
VexClaimStatus.UnderInvestigation => "under_investigation",
_ => status.ToString().ToLowerInvariant(),
};
}

View File

@@ -0,0 +1,196 @@
using System;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.AdvisoryAI.Providers;
public interface ISbomContextClient
{
Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken);
}
public sealed class SbomContextQuery
{
public SbomContextQuery(
string artifactId,
string? purl,
int maxTimelineEntries,
int maxDependencyPaths,
bool includeEnvironmentFlags,
bool includeBlastRadius)
{
if (string.IsNullOrWhiteSpace(artifactId))
{
throw new ArgumentException("ArtifactId must be provided.", nameof(artifactId));
}
ArtifactId = artifactId.Trim();
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
MaxTimelineEntries = Math.Max(0, maxTimelineEntries);
MaxDependencyPaths = Math.Max(0, maxDependencyPaths);
IncludeEnvironmentFlags = includeEnvironmentFlags;
IncludeBlastRadius = includeBlastRadius;
}
public string ArtifactId { get; }
public string? Purl { get; }
public int MaxTimelineEntries { get; }
public int MaxDependencyPaths { get; }
public bool IncludeEnvironmentFlags { get; }
public bool IncludeBlastRadius { get; }
}
public sealed class SbomContextDocument
{
public SbomContextDocument(
string artifactId,
string? purl,
ImmutableArray<SbomVersionRecord> versions,
ImmutableArray<SbomDependencyPathRecord> dependencyPaths,
ImmutableDictionary<string, string> environmentFlags,
SbomBlastRadiusRecord? blastRadius,
ImmutableDictionary<string, string> metadata)
{
if (string.IsNullOrWhiteSpace(artifactId))
{
throw new ArgumentException("ArtifactId must be provided.", nameof(artifactId));
}
ArtifactId = artifactId.Trim();
Purl = string.IsNullOrWhiteSpace(purl) ? null : purl.Trim();
Versions = versions.IsDefault ? ImmutableArray<SbomVersionRecord>.Empty : versions;
DependencyPaths = dependencyPaths.IsDefault ? ImmutableArray<SbomDependencyPathRecord>.Empty : dependencyPaths;
EnvironmentFlags = environmentFlags == default ? ImmutableDictionary<string, string>.Empty : environmentFlags;
BlastRadius = blastRadius;
Metadata = metadata == default ? ImmutableDictionary<string, string>.Empty : metadata;
}
public string ArtifactId { get; }
public string? Purl { get; }
public ImmutableArray<SbomVersionRecord> Versions { get; }
public ImmutableArray<SbomDependencyPathRecord> DependencyPaths { get; }
public ImmutableDictionary<string, string> EnvironmentFlags { get; }
public SbomBlastRadiusRecord? BlastRadius { get; }
public ImmutableDictionary<string, string> Metadata { get; }
}
public sealed class SbomVersionRecord
{
public SbomVersionRecord(
string version,
DateTimeOffset firstObserved,
DateTimeOffset? lastObserved,
string status,
string source,
bool isFixAvailable,
ImmutableDictionary<string, string> metadata)
{
if (string.IsNullOrWhiteSpace(version))
{
throw new ArgumentException("Version must be provided.", nameof(version));
}
Version = version.Trim();
FirstObserved = firstObserved;
LastObserved = lastObserved;
Status = string.IsNullOrWhiteSpace(status) ? "unknown" : status.Trim();
Source = string.IsNullOrWhiteSpace(source) ? "unknown" : source.Trim();
IsFixAvailable = isFixAvailable;
Metadata = metadata == default ? ImmutableDictionary<string, string>.Empty : metadata;
}
public string Version { get; }
public DateTimeOffset FirstObserved { get; }
public DateTimeOffset? LastObserved { get; }
public string Status { get; }
public string Source { get; }
public bool IsFixAvailable { get; }
public ImmutableDictionary<string, string> Metadata { get; }
}
public sealed class SbomDependencyPathRecord
{
public SbomDependencyPathRecord(
ImmutableArray<SbomDependencyNodeRecord> nodes,
bool isRuntime,
string? source,
ImmutableDictionary<string, string> metadata)
{
Nodes = nodes.IsDefault ? ImmutableArray<SbomDependencyNodeRecord>.Empty : nodes;
IsRuntime = isRuntime;
Source = string.IsNullOrWhiteSpace(source) ? null : source.Trim();
Metadata = metadata == default ? ImmutableDictionary<string, string>.Empty : metadata;
}
public ImmutableArray<SbomDependencyNodeRecord> Nodes { get; }
public bool IsRuntime { get; }
public string? Source { get; }
public ImmutableDictionary<string, string> Metadata { get; }
}
public sealed class SbomDependencyNodeRecord
{
public SbomDependencyNodeRecord(string identifier, string? version)
{
if (string.IsNullOrWhiteSpace(identifier))
{
throw new ArgumentException("Identifier must be provided.", nameof(identifier));
}
Identifier = identifier.Trim();
Version = string.IsNullOrWhiteSpace(version) ? null : version.Trim();
}
public string Identifier { get; }
public string? Version { get; }
}
public sealed class SbomBlastRadiusRecord
{
public SbomBlastRadiusRecord(
int impactedAssets,
int impactedWorkloads,
int impactedNamespaces,
double? impactedPercentage,
ImmutableDictionary<string, string> metadata)
{
ImpactedAssets = impactedAssets;
ImpactedWorkloads = impactedWorkloads;
ImpactedNamespaces = impactedNamespaces;
ImpactedPercentage = impactedPercentage;
Metadata = metadata == default ? ImmutableDictionary<string, string>.Empty : metadata;
}
public int ImpactedAssets { get; }
public int ImpactedWorkloads { get; }
public int ImpactedNamespaces { get; }
public double? ImpactedPercentage { get; }
public ImmutableDictionary<string, string> Metadata { get; }
}

View File

@@ -0,0 +1,71 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Chunking;
using StellaOps.AdvisoryAI.Documents;
namespace StellaOps.AdvisoryAI.Retrievers;
internal sealed class AdvisoryStructuredRetriever : IAdvisoryStructuredRetriever
{
private readonly IAdvisoryDocumentProvider _documentProvider;
private readonly DocumentChunkerFactory _chunkerFactory;
private readonly ILogger<AdvisoryStructuredRetriever>? _logger;
public AdvisoryStructuredRetriever(
IAdvisoryDocumentProvider documentProvider,
IEnumerable<IDocumentChunker> chunkers,
ILogger<AdvisoryStructuredRetriever>? logger = null)
{
_documentProvider = documentProvider ?? throw new ArgumentNullException(nameof(documentProvider));
_chunkerFactory = new DocumentChunkerFactory(chunkers ?? throw new ArgumentNullException(nameof(chunkers)));
_logger = logger;
}
public async Task<AdvisoryRetrievalResult> RetrieveAsync(AdvisoryRetrievalRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var documents = await _documentProvider.GetDocumentsAsync(request.AdvisoryKey, cancellationToken).ConfigureAwait(false);
if (documents.Count == 0)
{
_logger?.LogWarning("No documents returned for advisory {AdvisoryKey}", request.AdvisoryKey);
return AdvisoryRetrievalResult.Create(request.AdvisoryKey, Array.Empty<AdvisoryChunk>());
}
var preferredSections = request.PreferredSections is null
? null
: request.PreferredSections.Select(section => section.Trim()).Where(static s => s.Length > 0).ToHashSet(StringComparer.OrdinalIgnoreCase);
var chunks = new List<AdvisoryChunk>();
foreach (var document in documents)
{
var chunker = _chunkerFactory.Resolve(document.Format);
foreach (var chunk in chunker.Chunk(document))
{
if (preferredSections is not null && !preferredSections.Contains(chunk.Section))
{
continue;
}
chunks.Add(chunk);
}
}
chunks.Sort(static (left, right) => string.CompareOrdinal(left.ChunkId, right.ChunkId));
if (request.MaxChunks is int max && max > 0 && chunks.Count > max)
{
chunks = chunks.Take(max).ToList();
}
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
{
["documents"] = string.Join(",", documents.Select(d => d.DocumentId)),
["chunk_count"] = chunks.Count.ToString(),
};
return AdvisoryRetrievalResult.Create(request.AdvisoryKey, chunks, metadata);
}
}

View File

@@ -0,0 +1,73 @@
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Vectorization;
namespace StellaOps.AdvisoryAI.Retrievers;
internal sealed class AdvisoryVectorRetriever : IAdvisoryVectorRetriever
{
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
private readonly IVectorEncoder _encoder;
public AdvisoryVectorRetriever(IAdvisoryStructuredRetriever structuredRetriever, IVectorEncoder encoder)
{
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
_encoder = encoder ?? throw new ArgumentNullException(nameof(encoder));
}
public async Task<IReadOnlyList<VectorRetrievalMatch>> SearchAsync(VectorRetrievalRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
if (request.TopK <= 0)
{
throw new ArgumentOutOfRangeException(nameof(request.TopK), "TopK must be a positive integer.");
}
var retrieval = await _structuredRetriever.RetrieveAsync(request.Retrieval, cancellationToken).ConfigureAwait(false);
if (retrieval.Chunks.Count == 0)
{
return Array.Empty<VectorRetrievalMatch>();
}
var queryVector = _encoder.Encode(request.Query);
var matches = new List<VectorRetrievalMatch>(retrieval.Chunks.Count);
foreach (var chunk in retrieval.Chunks)
{
var vector = chunk.Embedding ?? _encoder.Encode(chunk.Text);
var score = CosineSimilarity(queryVector, vector);
matches.Add(new VectorRetrievalMatch(chunk.DocumentId, chunk.ChunkId, chunk.Text, score, chunk.Metadata));
}
matches.Sort(static (left, right) => right.Score.CompareTo(left.Score));
if (matches.Count > request.TopK)
{
matches.RemoveRange(request.TopK, matches.Count - request.TopK);
}
return matches;
}
private static double CosineSimilarity(float[] left, float[] right)
{
var length = Math.Min(left.Length, right.Length);
double dot = 0;
double leftNorm = 0;
double rightNorm = 0;
for (var i = 0; i < length; i++)
{
var l = left[i];
var r = right[i];
dot += l * r;
leftNorm += l * l;
rightNorm += r * r;
}
if (leftNorm <= 0 || rightNorm <= 0)
{
return 0;
}
return dot / Math.Sqrt(leftNorm * rightNorm);
}
}

View File

@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Providers;
namespace StellaOps.AdvisoryAI.Retrievers;
internal sealed class SbomContextRetriever : ISbomContextRetriever
{
private readonly ISbomContextClient _client;
private readonly ILogger<SbomContextRetriever>? _logger;
public SbomContextRetriever(ISbomContextClient client, ILogger<SbomContextRetriever>? logger = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger;
}
public async Task<SbomContextResult> RetrieveAsync(SbomContextRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var query = new SbomContextQuery(
request.ArtifactId,
request.Purl,
request.MaxTimelineEntries,
request.MaxDependencyPaths,
request.IncludeEnvironmentFlags,
request.IncludeBlastRadius);
SbomContextDocument? document;
try
{
document = await _client.GetContextAsync(query, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to retrieve SBOM context for artifact {ArtifactId}", request.ArtifactId);
document = null;
}
if (document is null)
{
_logger?.LogWarning("No SBOM context returned for artifact {ArtifactId}", request.ArtifactId);
return SbomContextResult.Empty(request.ArtifactId, request.Purl);
}
var timeline = ShapeTimeline(document.Versions, request.MaxTimelineEntries);
var paths = ShapeDependencyPaths(document.DependencyPaths, request.MaxDependencyPaths);
var environmentFlags = request.IncludeEnvironmentFlags
? NormalizeEnvironmentFlags(document.EnvironmentFlags)
: ImmutableDictionary<string, string>.Empty;
var blastRadius = request.IncludeBlastRadius ? ShapeBlastRadius(document.BlastRadius) : null;
var metadata = BuildMetadata(document, timeline.Count, paths.Count, environmentFlags.Count, blastRadius is not null);
return SbomContextResult.Create(
document.ArtifactId,
document.Purl,
timeline,
paths,
environmentFlags,
blastRadius,
metadata);
}
private static IReadOnlyList<SbomVersionTimelineEntry> ShapeTimeline(ImmutableArray<SbomVersionRecord> versions, int max)
{
if (versions.IsDefaultOrEmpty || max == 0)
{
return Array.Empty<SbomVersionTimelineEntry>();
}
return versions
.OrderBy(static v => v.FirstObserved)
.ThenBy(static v => v.Version, StringComparer.Ordinal)
.Take(max > 0 ? max : int.MaxValue)
.Select(static v => new SbomVersionTimelineEntry(
v.Version,
v.FirstObserved,
v.LastObserved,
string.IsNullOrWhiteSpace(v.Status)
? (v.IsFixAvailable ? "fixed" : "unknown")
: v.Status.Trim(),
string.IsNullOrWhiteSpace(v.Source) ? "sbom" : v.Source.Trim()))
.ToImmutableArray();
}
private static IReadOnlyList<SbomDependencyPath> ShapeDependencyPaths(ImmutableArray<SbomDependencyPathRecord> paths, int max)
{
if (paths.IsDefaultOrEmpty || max == 0)
{
return Array.Empty<SbomDependencyPath>();
}
var distinct = new SortedDictionary<string, SbomDependencyPath>(StringComparer.Ordinal);
foreach (var path in paths)
{
if (path.Nodes.IsDefaultOrEmpty)
{
continue;
}
var nodeList = path.Nodes
.Select(static node => new SbomDependencyNode(node.Identifier, node.Version))
.ToImmutableArray();
if (nodeList.IsDefaultOrEmpty)
{
continue;
}
var key = string.Join(
"|",
nodeList.Select(static n => string.Concat(n.Identifier, "@", n.Version ?? string.Empty)));
if (distinct.ContainsKey(key))
{
continue;
}
var dependencyPath = new SbomDependencyPath(
nodeList,
path.IsRuntime,
string.IsNullOrWhiteSpace(path.Source) ? null : path.Source.Trim());
distinct[key] = dependencyPath;
}
return distinct.Values
.OrderBy(p => p.IsRuntime ? 0 : 1)
.ThenBy(p => p.Nodes.Length)
.ThenBy(p => string.Join("|", p.Nodes.Select(n => n.Identifier)), StringComparer.Ordinal)
.Take(max > 0 ? max : int.MaxValue)
.ToImmutableArray();
}
private static IReadOnlyDictionary<string, string> NormalizeEnvironmentFlags(ImmutableDictionary<string, string> flags)
{
if (flags == default || flags.IsEmpty)
{
return ImmutableDictionary<string, string>.Empty;
}
var builder = ImmutableSortedDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in flags)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
builder[pair.Key.Trim()] = pair.Value.Trim();
}
return builder.ToImmutable();
}
private static SbomBlastRadiusSummary? ShapeBlastRadius(SbomBlastRadiusRecord? record)
{
if (record is null)
{
return null;
}
var metadata = record.Metadata == default
? ImmutableDictionary<string, string>.Empty
: record.Metadata.ToImmutableDictionary(StringComparer.Ordinal);
return new SbomBlastRadiusSummary(
record.ImpactedAssets,
record.ImpactedWorkloads,
record.ImpactedNamespaces,
record.ImpactedPercentage,
metadata);
}
private static IReadOnlyDictionary<string, string> BuildMetadata(
SbomContextDocument document,
int timelineCount,
int pathCount,
int environmentFlagCount,
bool hasBlastRadius)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in document.Metadata)
{
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
{
continue;
}
builder[pair.Key.Trim()] = pair.Value.Trim();
}
builder["version_count"] = timelineCount.ToString();
builder["dependency_path_count"] = pathCount.ToString();
builder["environment_flag_count"] = environmentFlagCount.ToString();
builder["blast_radius_present"] = hasBlastRadius.ToString();
return builder.ToImmutable();
}
}

View File

@@ -10,6 +10,11 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.Text.Json" Version="10.0.0-rc.2.25502.2" />
<PackageReference Include="System.Text.Json" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<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>
</Project>

View File

@@ -1,12 +1,16 @@
# Advisory AI Task Board — Epic 8
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AIAI-31-001 | DOING (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
| AIAI-31-002 | TODO | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
| AIAI-31-003 | TODO | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
| AIAI-31-004 | TODO | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. |
| AIAI-31-009 | TODO | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. |
# Advisory AI Task Board — Epic 8
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AIAI-31-001 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement structured and vector retrievers for advisories/VEX with paragraph anchors and citation metadata. | Retrievers return deterministic chunks with source IDs/sections; unit tests cover CSAF/OSV/vendor formats. |
| AIAI-31-002 | DOING | Advisory AI Guild, SBOM Service Guild | SBOM-VULN-29-001 | Build SBOM context retriever (purl version timelines, dependency paths, env flags, blast radius estimator). | Retriever returns paths/metrics under SLA; tests cover ecosystems. |
| AIAI-31-003 | TODO | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
| AIAI-31-004 | TODO | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
| AIAI-31-005 | TODO | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
| AIAI-31-006 | TODO | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. |
| AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. |
| AIAI-31-011 | DONE (2025-11-02) | Advisory AI Guild | EXCITITOR-LNM-21-201, EXCITITOR-CORE-AOC-19-002 | Implement Excititor VEX document provider to surface structured VEX statements for vector retrieval. | Provider returns conflict-aware VEX chunks with deterministic metadata and tests for representative statements. |
| AIAI-31-009 | TODO | Advisory AI Guild, QA Guild | AIAI-31-001..006 | Develop unit/golden/property/perf tests, injection harness, and regression suite; ensure determinism with seeded caches. | Test suite green; golden outputs stored; injection tests pass; perf targets documented. |
> 2025-11-02: AIAI-31-002 SBOM context domain models finalized with limiter guards; retriever tests now cover flag toggles and path dedupe. Service client integration still pending with SBOM guild.

View File

@@ -0,0 +1,77 @@
using System.Buffers;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace StellaOps.AdvisoryAI.Vectorization;
internal interface IVectorEncoder
{
float[] Encode(string text);
}
internal sealed class DeterministicHashVectorEncoder : IVectorEncoder, IDisposable
{
private const int DefaultDimensions = 64;
private static readonly Regex TokenRegex = new("[A-Za-z0-9]+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly IncrementalHash _hash;
private readonly int _dimensions;
public DeterministicHashVectorEncoder(int dimensions = DefaultDimensions)
{
if (dimensions <= 0)
{
throw new ArgumentOutOfRangeException(nameof(dimensions));
}
_dimensions = dimensions;
_hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
}
public float[] Encode(string text)
{
ArgumentNullException.ThrowIfNull(text);
var vector = new float[_dimensions];
var tokenMatches = TokenRegex.Matches(text);
if (tokenMatches.Count == 0)
{
return vector;
}
Span<byte> hash = stackalloc byte[32];
foreach (Match match in tokenMatches)
{
var token = match.Value.ToLowerInvariant();
var bytes = Encoding.UTF8.GetBytes(token);
_hash.AppendData(bytes);
_hash.GetHashAndReset(hash);
var index = (int)(BitConverter.ToUInt32(hash[..4]) % (uint)_dimensions);
vector[index] += 1f;
}
Normalize(vector);
return vector;
}
private static void Normalize(float[] vector)
{
var sumSquares = vector.Sum(v => v * v);
if (sumSquares <= 0f)
{
return;
}
var length = MathF.Sqrt(sumSquares);
for (var i = 0; i < vector.Length; i++)
{
vector[i] /= length;
}
}
public void Dispose()
{
_hash.Dispose();
}
}