Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
262 lines
9.3 KiB
C#
262 lines
9.3 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Signals.Models;
|
|
using StellaOps.Signals.Options;
|
|
|
|
namespace StellaOps.Signals.Services;
|
|
|
|
/// <summary>
|
|
/// Ingests edge-bundle DSSE envelopes, attaches to graph_hash, enforces quarantine for revoked edges.
|
|
/// </summary>
|
|
public sealed class EdgeBundleIngestionService : IEdgeBundleIngestionService
|
|
{
|
|
private readonly ILogger<EdgeBundleIngestionService> _logger;
|
|
private readonly SignalsOptions _options;
|
|
|
|
// In-memory storage (in production, would use repository)
|
|
private readonly ConcurrentDictionary<string, List<EdgeBundleDocument>> _bundlesByGraphHash = new();
|
|
private readonly ConcurrentDictionary<string, HashSet<string>> _revokedEdgeKeys = new();
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
PropertyNameCaseInsensitive = true
|
|
};
|
|
|
|
public EdgeBundleIngestionService(
|
|
ILogger<EdgeBundleIngestionService> logger,
|
|
IOptions<SignalsOptions> options)
|
|
{
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
|
}
|
|
|
|
public async Task<EdgeBundleIngestResponse> IngestAsync(
|
|
string tenantId,
|
|
Stream bundleStream,
|
|
Stream? dsseStream,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
|
ArgumentNullException.ThrowIfNull(bundleStream);
|
|
|
|
// Parse the bundle JSON
|
|
using var bundleMs = new MemoryStream();
|
|
await bundleStream.CopyToAsync(bundleMs, cancellationToken).ConfigureAwait(false);
|
|
bundleMs.Position = 0;
|
|
|
|
var bundleJson = await JsonDocument.ParseAsync(bundleMs, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
var root = bundleJson.RootElement;
|
|
|
|
// Extract bundle fields
|
|
var bundleId = GetStringOrDefault(root, "bundleId", $"bundle:{Guid.NewGuid():N}");
|
|
var graphHash = GetStringOrDefault(root, "graphHash", string.Empty);
|
|
var bundleReason = GetStringOrDefault(root, "bundleReason", "Custom");
|
|
var customReason = GetStringOrDefault(root, "customReason", null);
|
|
var generatedAtStr = GetStringOrDefault(root, "generatedAt", null);
|
|
|
|
if (string.IsNullOrWhiteSpace(graphHash))
|
|
{
|
|
throw new InvalidOperationException("Edge bundle missing required 'graphHash' field");
|
|
}
|
|
|
|
var generatedAt = !string.IsNullOrWhiteSpace(generatedAtStr)
|
|
? DateTimeOffset.Parse(generatedAtStr)
|
|
: DateTimeOffset.UtcNow;
|
|
|
|
// Parse edges
|
|
var edges = new List<EdgeBundleEdgeDocument>();
|
|
var revokedCount = 0;
|
|
|
|
if (root.TryGetProperty("edges", out var edgesElement) && edgesElement.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var edgeEl in edgesElement.EnumerateArray())
|
|
{
|
|
var edge = new EdgeBundleEdgeDocument
|
|
{
|
|
From = GetStringOrDefault(edgeEl, "from", string.Empty),
|
|
To = GetStringOrDefault(edgeEl, "to", string.Empty),
|
|
Kind = GetStringOrDefault(edgeEl, "kind", "call"),
|
|
Reason = GetStringOrDefault(edgeEl, "reason", "Unknown"),
|
|
Revoked = edgeEl.TryGetProperty("revoked", out var r) && r.GetBoolean(),
|
|
Confidence = edgeEl.TryGetProperty("confidence", out var c) ? c.GetDouble() : 0.5,
|
|
Purl = GetStringOrDefault(edgeEl, "purl", null),
|
|
SymbolDigest = GetStringOrDefault(edgeEl, "symbolDigest", null),
|
|
Evidence = GetStringOrDefault(edgeEl, "evidence", null)
|
|
};
|
|
|
|
edges.Add(edge);
|
|
|
|
if (edge.Revoked)
|
|
{
|
|
revokedCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute content hash
|
|
bundleMs.Position = 0;
|
|
var contentHash = ComputeSha256(bundleMs);
|
|
|
|
// Parse DSSE if provided
|
|
string? dsseDigest = null;
|
|
if (dsseStream is not null)
|
|
{
|
|
using var dsseMs = new MemoryStream();
|
|
await dsseStream.CopyToAsync(dsseMs, cancellationToken).ConfigureAwait(false);
|
|
dsseMs.Position = 0;
|
|
dsseDigest = $"sha256:{ComputeSha256(dsseMs)}";
|
|
}
|
|
|
|
// Build CAS URIs
|
|
var graphHashDigest = ExtractHashDigest(graphHash);
|
|
var casUri = $"cas://reachability/edges/{graphHashDigest}/{bundleId}";
|
|
var dsseCasUri = dsseStream is not null ? $"{casUri}.dsse" : null;
|
|
|
|
// Create document
|
|
var document = new EdgeBundleDocument
|
|
{
|
|
BundleId = bundleId,
|
|
GraphHash = graphHash,
|
|
TenantId = tenantId,
|
|
BundleReason = bundleReason,
|
|
CustomReason = customReason,
|
|
Edges = edges,
|
|
ContentHash = $"sha256:{contentHash}",
|
|
DsseDigest = dsseDigest,
|
|
CasUri = casUri,
|
|
DsseCasUri = dsseCasUri,
|
|
Verified = dsseStream is not null, // Simple verification - in production would verify signature
|
|
RevokedCount = revokedCount,
|
|
IngestedAt = DateTimeOffset.UtcNow,
|
|
GeneratedAt = generatedAt
|
|
};
|
|
|
|
// Store document
|
|
var storageKey = $"{tenantId}:{graphHash}";
|
|
_bundlesByGraphHash.AddOrUpdate(
|
|
storageKey,
|
|
_ => new List<EdgeBundleDocument> { document },
|
|
(_, list) =>
|
|
{
|
|
// Remove existing bundle with same ID
|
|
list.RemoveAll(b => b.BundleId == bundleId);
|
|
list.Add(document);
|
|
return list;
|
|
});
|
|
|
|
// Update revoked edge index for quarantine enforcement
|
|
if (revokedCount > 0)
|
|
{
|
|
var revokedEdges = edges.Where(e => e.Revoked).Select(e => $"{e.From}>{e.To}").ToHashSet();
|
|
_revokedEdgeKeys.AddOrUpdate(
|
|
storageKey,
|
|
_ => revokedEdges,
|
|
(_, existing) =>
|
|
{
|
|
foreach (var key in revokedEdges)
|
|
{
|
|
existing.Add(key);
|
|
}
|
|
return existing;
|
|
});
|
|
}
|
|
|
|
var quarantined = revokedCount > 0;
|
|
|
|
_logger.LogInformation(
|
|
"Ingested edge bundle {BundleId} for graph {GraphHash} with {EdgeCount} edges ({RevokedCount} revoked, quarantine={Quarantined})",
|
|
bundleId, graphHash, edges.Count, revokedCount, quarantined);
|
|
|
|
return new EdgeBundleIngestResponse(
|
|
bundleId,
|
|
graphHash,
|
|
bundleReason,
|
|
casUri,
|
|
dsseCasUri,
|
|
edges.Count,
|
|
revokedCount,
|
|
quarantined);
|
|
}
|
|
|
|
public Task<EdgeBundleDocument[]> GetBundlesForGraphAsync(
|
|
string tenantId,
|
|
string graphHash,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var key = $"{tenantId}:{graphHash}";
|
|
if (_bundlesByGraphHash.TryGetValue(key, out var bundles))
|
|
{
|
|
return Task.FromResult(bundles.ToArray());
|
|
}
|
|
|
|
return Task.FromResult(Array.Empty<EdgeBundleDocument>());
|
|
}
|
|
|
|
public Task<EdgeBundleEdgeDocument[]> GetRevokedEdgesAsync(
|
|
string tenantId,
|
|
string graphHash,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var key = $"{tenantId}:{graphHash}";
|
|
if (_bundlesByGraphHash.TryGetValue(key, out var bundles))
|
|
{
|
|
var revoked = bundles
|
|
.SelectMany(b => b.Edges)
|
|
.Where(e => e.Revoked)
|
|
.ToArray();
|
|
return Task.FromResult(revoked);
|
|
}
|
|
|
|
return Task.FromResult(Array.Empty<EdgeBundleEdgeDocument>());
|
|
}
|
|
|
|
public Task<bool> IsEdgeRevokedAsync(
|
|
string tenantId,
|
|
string graphHash,
|
|
string fromId,
|
|
string toId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var key = $"{tenantId}:{graphHash}";
|
|
if (_revokedEdgeKeys.TryGetValue(key, out var revokedKeys))
|
|
{
|
|
var edgeKey = $"{fromId}>{toId}";
|
|
return Task.FromResult(revokedKeys.Contains(edgeKey));
|
|
}
|
|
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
private static string GetStringOrDefault(JsonElement element, string propertyName, string? defaultValue)
|
|
{
|
|
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
|
{
|
|
return prop.GetString() ?? defaultValue ?? string.Empty;
|
|
}
|
|
return defaultValue ?? string.Empty;
|
|
}
|
|
|
|
private static string ComputeSha256(Stream stream)
|
|
{
|
|
using var sha = SHA256.Create();
|
|
var hash = sha.ComputeHash(stream);
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
private static string ExtractHashDigest(string prefixedHash)
|
|
{
|
|
var colonIndex = prefixedHash.IndexOf(':');
|
|
return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash;
|
|
}
|
|
}
|