up
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
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
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user