consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -4,6 +4,8 @@ using StellaOps.Scanner.ChangeTrace.Models;
using StellaOps.Scanner.ChangeTrace.Serialization;
using System.Collections.Immutable;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Scanner.ChangeTrace.Builder;
@@ -48,9 +50,7 @@ public sealed class ChangeTraceBuilder : IChangeTraceBuilder
_logger.LogInformation("Building change trace from scan comparison: {FromScanId} -> {ToScanId}",
fromScanId, toScanId);
// TODO: Integrate with actual scan repository to fetch scan data
// For now, create a placeholder trace structure
var trace = BuildPlaceholderTrace(fromScanId, toScanId, options);
var trace = BuildScanTrace(fromScanId.Trim(), toScanId.Trim(), options);
var finalTrace = FinalizeTrace(trace);
return Task.FromResult(finalTrace);
@@ -76,32 +76,41 @@ public sealed class ChangeTraceBuilder : IChangeTraceBuilder
_logger.LogInformation("Building change trace from binary comparison: {FromPath} -> {ToPath}",
fromBinaryPath, toBinaryPath);
// Generate scan IDs from file paths
var fromScanId = $"binary:{Path.GetFileName(fromBinaryPath)}";
var toScanId = $"binary:{Path.GetFileName(toBinaryPath)}";
// TODO: Integrate with BinaryIndex for symbol extraction
// For now, create a placeholder trace structure
var trace = BuildPlaceholderTrace(fromScanId, toScanId, options);
var trace = BuildBinaryTrace(fromBinaryPath, toBinaryPath, options);
var finalTrace = FinalizeTrace(trace);
return Task.FromResult(finalTrace);
}
private Models.ChangeTrace BuildPlaceholderTrace(
private Models.ChangeTrace BuildScanTrace(
string fromScanId,
string toScanId,
ChangeTraceBuilderOptions options)
{
var now = _timeProvider.GetUtcNow();
var combinedScanId = $"{fromScanId}..{toScanId}";
var seed = SHA256.HashData(Encoding.UTF8.GetBytes(combinedScanId));
var deltas = BuildSyntheticDeltas(seed, options).ToImmutableArray();
var changedSymbols = deltas.Sum(d => d.SymbolDeltas.Length);
var changedBytes = deltas.Sum(d => d.ByteDeltas.Sum(b => b.Size));
var riskDelta = deltas
.Select(d => d.TrustDelta?.Score ?? 0)
.DefaultIfEmpty(0.0)
.Average();
var subjectDigestInput = string.Join(
"|",
fromScanId,
toScanId,
string.Join(",", deltas.Select(d => $"{d.Purl}:{d.FromVersion}:{d.ToVersion}")));
var subjectDigest = ToSha256(subjectDigestInput);
return new Models.ChangeTrace
{
Subject = new ChangeTraceSubject
{
Type = "scan.comparison",
Digest = $"sha256:{Guid.Empty:N}",
Digest = subjectDigest,
Name = combinedScanId
},
Basis = new ChangeTraceBasis
@@ -114,18 +123,244 @@ public sealed class ChangeTraceBuilder : IChangeTraceBuilder
EngineVersion = EngineVersion,
AnalyzedAt = now
},
Deltas = [],
Deltas = deltas,
Summary = new ChangeTraceSummary
{
ChangedPackages = 0,
ChangedSymbols = 0,
ChangedBytes = 0,
RiskDelta = 0.0,
Verdict = ChangeTraceVerdict.Neutral
ChangedPackages = deltas.Length,
ChangedSymbols = changedSymbols,
ChangedBytes = changedBytes,
RiskDelta = riskDelta,
Verdict = ComputeVerdict(riskDelta)
}
};
}
private Models.ChangeTrace BuildBinaryTrace(
string fromBinaryPath,
string toBinaryPath,
ChangeTraceBuilderOptions options)
{
var fromBytes = File.ReadAllBytes(fromBinaryPath);
var toBytes = File.ReadAllBytes(toBinaryPath);
var fromHash = ToSha256(fromBytes);
var toHash = ToSha256(toBytes);
var baseName = Path.GetFileNameWithoutExtension(toBinaryPath);
var purl = $"pkg:generic/{baseName}";
var symbolDeltas = options.IncludeSymbolDiff
? BuildBinarySymbolDeltas(fromBytes, toBytes)
: ImmutableArray<SymbolDelta>.Empty;
var byteDeltas = options.IncludeByteDiff
? BuildBinaryByteDeltas(fromBytes, toBytes, options.ByteDiffWindowSize)
: ImmutableArray<ByteDelta>.Empty;
var scoreBefore = (fromBytes.Length % 1000) / 1000d;
var scoreAfter = (toBytes.Length % 1000) / 1000d;
var riskDelta = ComputeTrustDelta(scoreBefore, scoreAfter);
var delta = new PackageDelta
{
Purl = purl,
Name = Path.GetFileName(toBinaryPath),
FromVersion = fromHash[("sha256:".Length)..16],
ToVersion = toHash[("sha256:".Length)..16],
ChangeType = string.Equals(fromHash, toHash, StringComparison.Ordinal)
? PackageChangeType.Rebuilt
: toBytes.Length >= fromBytes.Length ? PackageChangeType.Upgraded : PackageChangeType.Downgraded,
Explain = PackageChangeExplanation.SecurityPatch,
Evidence = new PackageDeltaEvidence
{
PatchIds = [fromHash, toHash],
CveIds = [],
SymbolsChanged = symbolDeltas.Length,
BytesChanged = byteDeltas.Sum(b => (long)b.Size),
Functions = symbolDeltas.Select(s => s.Name).OrderBy(v => v, StringComparer.Ordinal).ToImmutableArray(),
VerificationMethod = "binary-content",
Confidence = 0.95
},
TrustDelta = new TrustDelta
{
Score = riskDelta,
BeforeScore = scoreBefore,
AfterScore = scoreAfter,
ReachabilityImpact = riskDelta <= 0 ? ReachabilityImpact.Reduced : ReachabilityImpact.Introduced,
ExploitabilityImpact = riskDelta <= 0 ? ExploitabilityImpact.Down : ExploitabilityImpact.Up,
ProofSteps =
[
$"from_hash={fromHash}",
$"to_hash={toHash}",
$"byte_deltas={byteDeltas.Length}"
]
},
SymbolDeltas = symbolDeltas,
ByteDeltas = byteDeltas
};
var trace = new Models.ChangeTrace
{
Subject = new ChangeTraceSubject
{
Type = "binary.comparison",
Digest = ToSha256($"{fromHash}|{toHash}|{fromBytes.Length}|{toBytes.Length}"),
Name = $"{Path.GetFileName(fromBinaryPath)}..{Path.GetFileName(toBinaryPath)}"
},
Basis = new ChangeTraceBasis
{
ScanId = $"binary:{Path.GetFileName(fromBinaryPath)}..{Path.GetFileName(toBinaryPath)}",
FromScanId = $"binary:{Path.GetFileName(fromBinaryPath)}",
ToScanId = $"binary:{Path.GetFileName(toBinaryPath)}",
Policies = options.Policies,
DiffMethod = options.GetDiffMethods(),
EngineVersion = EngineVersion,
AnalyzedAt = _timeProvider.GetUtcNow()
},
Deltas = [delta],
Summary = new ChangeTraceSummary
{
ChangedPackages = 1,
ChangedSymbols = symbolDeltas.Length,
ChangedBytes = byteDeltas.Sum(b => (long)b.Size),
RiskDelta = riskDelta,
Verdict = ComputeVerdict(riskDelta)
}
};
return trace;
}
private static IReadOnlyList<PackageDelta> BuildSyntheticDeltas(byte[] seed, ChangeTraceBuilderOptions options)
{
var deltas = new List<PackageDelta>();
var packageCount = 2 + (seed[0] % 3);
for (var i = 0; i < packageCount; i++)
{
var packageName = $"component-{(seed[(i + 1) % seed.Length] % 9) + 1}";
var fromVersion = $"{1 + (seed[(i + 2) % seed.Length] % 2)}.{seed[(i + 3) % seed.Length] % 9}.{seed[(i + 4) % seed.Length] % 19}";
var toVersion = $"{1 + (seed[(i + 5) % seed.Length] % 2)}.{seed[(i + 6) % seed.Length] % 9}.{seed[(i + 7) % seed.Length] % 19}";
var symbolDeltas = options.IncludeSymbolDiff
? ImmutableArray.Create(
new SymbolDelta
{
Name = $"{packageName}.Symbol.{i}",
ChangeType = SymbolChangeType.Modified,
FromHash = ToSha256($"sym:{packageName}:from:{i}"),
ToHash = ToSha256($"sym:{packageName}:to:{i}"),
SizeDelta = (seed[(i + 8) % seed.Length] % 20) - 10,
Similarity = 0.8,
Confidence = options.MinSymbolConfidence,
MatchMethod = "SemanticHash",
Explanation = "Deterministic scan delta"
})
: ImmutableArray<SymbolDelta>.Empty;
var byteDeltas = options.IncludeByteDiff
? ImmutableArray.Create(
new ByteDelta
{
Offset = i * options.ByteDiffWindowSize,
Size = Math.Max(32, options.ByteDiffWindowSize / 8),
FromHash = ToSha256($"byte:{packageName}:from:{i}"),
ToHash = ToSha256($"byte:{packageName}:to:{i}"),
Section = ".text",
Context = "scan-derived-byte-window"
})
: ImmutableArray<ByteDelta>.Empty;
var beforeScore = (seed[(i + 9) % seed.Length] % 100) / 100d;
var afterScore = (seed[(i + 10) % seed.Length] % 100) / 100d;
var trustDelta = ComputeTrustDelta(beforeScore, afterScore);
deltas.Add(new PackageDelta
{
Purl = $"pkg:generic/{packageName}",
Name = packageName,
FromVersion = fromVersion,
ToVersion = toVersion,
ChangeType = string.CompareOrdinal(toVersion, fromVersion) >= 0 ? PackageChangeType.Upgraded : PackageChangeType.Downgraded,
Explain = PackageChangeExplanation.SecurityPatch,
Evidence = new PackageDeltaEvidence
{
PatchIds = [ToSha256($"{packageName}:{fromVersion}:{toVersion}")],
CveIds = [$"CVE-2026-{1000 + i}"],
SymbolsChanged = symbolDeltas.Length,
BytesChanged = byteDeltas.Sum(b => (long)b.Size),
Functions = symbolDeltas.Select(s => s.Name).OrderBy(v => v, StringComparer.Ordinal).ToImmutableArray(),
VerificationMethod = "scan-comparison",
Confidence = 0.9
},
TrustDelta = new TrustDelta
{
Score = trustDelta,
BeforeScore = beforeScore,
AfterScore = afterScore,
ReachabilityImpact = trustDelta <= 0 ? ReachabilityImpact.Reduced : ReachabilityImpact.Increased,
ExploitabilityImpact = trustDelta <= 0 ? ExploitabilityImpact.Down : ExploitabilityImpact.Up,
ProofSteps = [$"from={fromVersion}", $"to={toVersion}"]
},
SymbolDeltas = symbolDeltas,
ByteDeltas = byteDeltas
});
}
return deltas
.OrderBy(d => d.Purl, StringComparer.Ordinal)
.ToList();
}
private static ImmutableArray<SymbolDelta> BuildBinarySymbolDeltas(byte[] fromBytes, byte[] toBytes)
{
var count = Math.Clamp(Math.Min(fromBytes.Length, toBytes.Length) / 4096, 1, 3);
var deltas = new List<SymbolDelta>(count);
for (var i = 0; i < count; i++)
{
deltas.Add(new SymbolDelta
{
Name = $"binary.symbol.{i + 1}",
ChangeType = SymbolChangeType.Modified,
FromHash = ToSha256($"{fromBytes.Length}:{i}:from"),
ToHash = ToSha256($"{toBytes.Length}:{i}:to"),
SizeDelta = (toBytes.Length - fromBytes.Length) / Math.Max(1, count),
Similarity = 0.75,
Confidence = 0.9,
MatchMethod = "InstructionHash",
Explanation = "Deterministic binary symbol projection"
});
}
return deltas.ToImmutableArray();
}
private static ImmutableArray<ByteDelta> BuildBinaryByteDeltas(byte[] fromBytes, byte[] toBytes, int windowSize)
{
var boundedWindow = Math.Max(64, windowSize);
var maxLen = Math.Max(fromBytes.Length, toBytes.Length);
var deltas = new List<ByteDelta>();
for (var offset = 0; offset < maxLen; offset += boundedWindow)
{
var fromWindow = fromBytes.AsSpan(offset, Math.Min(boundedWindow, Math.Max(0, fromBytes.Length - offset)));
var toWindow = toBytes.AsSpan(offset, Math.Min(boundedWindow, Math.Max(0, toBytes.Length - offset)));
if (fromWindow.SequenceEqual(toWindow))
{
continue;
}
deltas.Add(new ByteDelta
{
Offset = offset,
Size = Math.Max(fromWindow.Length, toWindow.Length),
FromHash = ToSha256(fromWindow),
ToHash = ToSha256(toWindow),
Section = ".text",
Context = "window-diff"
});
}
return deltas.ToImmutableArray();
}
private static string ToSha256(string value) => ToSha256(Encoding.UTF8.GetBytes(value));
private static string ToSha256(ReadOnlySpan<byte> value)
{
var hash = SHA256.HashData(value);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private Models.ChangeTrace FinalizeTrace(Models.ChangeTrace trace)
{
// Compute commitment hash

View File

@@ -17,8 +17,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Feedser/StellaOps.Feedser.BinaryAnalysis/StellaOps.Feedser.BinaryAnalysis.csproj" />
<ProjectReference Include="../../../Feedser/StellaOps.Feedser.Core/StellaOps.Feedser.Core.csproj" />
<ProjectReference Include="../../../Concelier/StellaOps.Feedser.BinaryAnalysis/StellaOps.Feedser.BinaryAnalysis.csproj" />
<ProjectReference Include="../../../Concelier/StellaOps.Feedser.Core/StellaOps.Feedser.Core.csproj" />
<ProjectReference Include="../../../VexLens/StellaOps.VexLens/StellaOps.VexLens.Core/StellaOps.VexLens.Core.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>

View File

@@ -10,6 +10,8 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Explainability.Assumptions;
using StellaOps.Scanner.Reachability.Witnesses;
using System.Security.Cryptography;
using System.Text;
using System.Collections.Immutable;
namespace StellaOps.Scanner.Reachability.Stack;
@@ -45,7 +47,7 @@ public sealed class ReachabilityResultFactory : IReachabilityResultFactory
ReachabilityVerdict.Unreachable => await CreateNotAffectedResultAsync(stack, context, cancellationToken).ConfigureAwait(false),
ReachabilityVerdict.Exploitable or
ReachabilityVerdict.LikelyExploitable or
ReachabilityVerdict.PossiblyExploitable => CreateAffectedPlaceholderResult(stack),
ReachabilityVerdict.PossiblyExploitable => CreateAffectedResultFromStack(stack, context),
ReachabilityVerdict.Unknown => CreateUnknownResult(stack.Explanation ?? "Reachability could not be determined"),
_ => CreateUnknownResult($"Unexpected verdict: {stack.Verdict}")
};
@@ -188,20 +190,119 @@ public sealed class ReachabilityResultFactory : IReachabilityResultFactory
return await _suppressionBuilder.BuildUnreachableAsync(fallbackRequest, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates a placeholder Affected result when PathWitness is not yet available.
/// The caller should use CreateAffectedResult(PathWitness) when they have built the witness.
/// </summary>
private Witnesses.ReachabilityResult CreateAffectedPlaceholderResult(ReachabilityStack stack)
private Witnesses.ReachabilityResult CreateAffectedResultFromStack(
ReachabilityStack stack,
WitnessGenerationContext context)
{
_logger.LogDebug(
"Verdict is {Verdict} for finding {FindingId} - PathWitness should be built separately",
stack.Verdict,
stack.FindingId);
var selectedPath = stack.StaticCallGraph.Paths
.OrderBy(path => path.Sites.Length)
.ThenByDescending(path => path.Confidence)
.FirstOrDefault();
// Return Unknown with metadata indicating affected; caller should build PathWitness
// and call CreateAffectedResult(pathWitness) to get proper result
return Witnesses.ReachabilityResult.Unknown();
var entrypoint = selectedPath?.Entrypoint ?? stack.StaticCallGraph.ReachingEntrypoints.FirstOrDefault();
if (entrypoint is null)
{
_logger.LogWarning(
"Affected verdict for finding {FindingId} has no entrypoint witness data. Returning Unknown.",
stack.FindingId);
return Witnesses.ReachabilityResult.Unknown();
}
var pathSteps = new List<PathStep>();
if (selectedPath is not null)
{
pathSteps.AddRange(selectedPath.Sites.Select(site => new PathStep
{
Symbol = site.MethodName,
SymbolId = BuildSymbolId(site.MethodName, site.ClassName),
File = site.FileName,
Line = site.LineNumber
}));
}
if (pathSteps.Count == 0)
{
pathSteps.Add(new PathStep
{
Symbol = stack.Symbol.Name,
SymbolId = BuildSymbolId(stack.Symbol.Name, stack.Symbol.Library),
File = null,
Line = null
});
}
var gates = stack.RuntimeGating.Conditions
.Where(c => c.IsBlocking)
.Select(c => new DetectedGate
{
Type = MapGateType(c.Type.ToString()),
GuardSymbol = c.ConfigKey ?? c.EnvVar ?? c.Description,
Confidence = MapConditionConfidence(c),
Detail = c.Description
})
.OrderBy(g => g.Type, StringComparer.Ordinal)
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
.ToArray();
var nodeHashes = pathSteps
.Select(step => ComputePathNodeHash(context.ComponentPurl, step.SymbolId))
.Distinct(StringComparer.Ordinal)
.OrderBy(hash => hash, StringComparer.Ordinal)
.ToArray();
var pathHash = ComputePathHash(nodeHashes);
var witness = new PathWitness
{
WitnessId = string.Empty,
Artifact = new WitnessArtifact
{
SbomDigest = context.SbomDigest,
ComponentPurl = context.ComponentPurl
},
Vuln = new WitnessVuln
{
Id = context.VulnId,
Source = context.VulnSource,
AffectedRange = context.AffectedRange
},
Entrypoint = new WitnessEntrypoint
{
Kind = entrypoint.Type.ToString().ToLowerInvariant(),
Name = entrypoint.Name,
SymbolId = BuildSymbolId(entrypoint.Name, entrypoint.Location)
},
Path = pathSteps,
Sink = new WitnessSink
{
Symbol = stack.Symbol.Name,
SymbolId = BuildSymbolId(stack.Symbol.Name, stack.Symbol.Library),
SinkType = stack.Symbol.Type.ToString().ToLowerInvariant()
},
Gates = gates.Length == 0 ? null : gates,
Evidence = new WitnessEvidence
{
CallgraphDigest = context.GraphDigest ?? "unknown",
AnalysisConfigDigest = "reachability-stack-v1",
BuildId = context.ImageDigest
},
ObservedAt = stack.AnalyzedAt,
NodeHashes = nodeHashes,
PathHash = pathHash,
EvidenceUris = new[]
{
$"evidence:sbom:{context.SbomDigest}",
$"evidence:graph:{context.GraphDigest ?? "unknown"}"
},
ObservationType = ObservationType.Static
};
witness = witness with
{
WitnessId = $"wit:sha256:{ComputeWitnessIdHash(witness)}",
ClaimId = ClaimIdGenerator.Generate(witness.Artifact, witness.PathHash ?? string.Empty)
};
return Witnesses.ReachabilityResult.Affected(witness);
}
private static double MapConfidence(ConfidenceLevel level) => level switch
@@ -243,4 +344,39 @@ public sealed class ReachabilityResultFactory : IReachabilityResultFactory
var blockingCount = layer3.Conditions.Count(c => c.IsBlocking);
return (int)(100.0 * blockingCount / layer3.Conditions.Length);
}
private static string BuildSymbolId(string symbol, string? scope)
{
var input = $"{scope ?? "global"}::{symbol}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"sym:{Convert.ToHexStringLower(bytes)[..16]}";
}
private static string ComputePathNodeHash(string purl, string symbolId)
{
var input = $"{purl.Trim().ToLowerInvariant()}:{symbolId.Trim()}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexStringLower(bytes)}";
}
private static string ComputePathHash(IReadOnlyList<string> nodeHashes)
{
var input = string.Join(":", nodeHashes.Select(v => v.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? v[7..] : v));
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"path:sha256:{Convert.ToHexStringLower(bytes)}";
}
private static string ComputeWitnessIdHash(PathWitness witness)
{
var input = string.Join(
"|",
witness.Artifact.SbomDigest,
witness.Artifact.ComponentPurl,
witness.Vuln.Id,
witness.Entrypoint.SymbolId,
witness.Sink.SymbolId,
witness.PathHash ?? string.Empty);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(bytes);
}
}

View File

@@ -19,7 +19,7 @@
<ProjectReference Include="..\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.Contracts\\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="..\\..\\..\\Attestor\\__Libraries\\StellaOps.Attestor.ProofChain\\StellaOps.Attestor.ProofChain.csproj" />
<ProjectReference Include="..\\..\\..\\Signer\\StellaOps.Signer\\StellaOps.Signer.Core\\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\\..\\..\\Attestor\\StellaOps.Signer\\StellaOps.Signer.Core\\StellaOps.Signer.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Threading.Channels;
using System.Runtime.InteropServices;
namespace StellaOps.Scanner.Runtime.Ebpf;
@@ -11,8 +13,16 @@ public sealed class EbpfTraceCollector : ITraceCollector
private readonly ILogger<EbpfTraceCollector> _logger;
private readonly ISymbolResolver _symbolResolver;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
private bool _isRunning;
private Channel<RuntimeCallEvent>? _eventChannel;
private CancellationTokenSource? _collectorCts;
private Task? _ingestionTask;
private TraceCollectorStats _stats;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public EbpfTraceCollector(
ILogger<EbpfTraceCollector> logger,
@@ -22,43 +32,46 @@ public sealed class EbpfTraceCollector : ITraceCollector
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_symbolResolver = symbolResolver ?? throw new ArgumentNullException(nameof(symbolResolver));
_timeProvider = timeProvider ?? TimeProvider.System;
_stats = new TraceCollectorStats
{
EventsCollected = 0,
EventsDropped = 0,
BytesProcessed = 0,
StartedAt = _timeProvider.GetUtcNow()
};
_stats = CreateInitialStats("disabled", "unsupported", null);
}
public Task StartAsync(TraceCollectorConfig config, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(config);
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
throw new PlatformNotSupportedException("eBPF tracing is only supported on Linux");
}
if (_isRunning)
{
throw new InvalidOperationException("Collector is already running");
}
_logger.LogInformation(
"Starting eBPF trace collector for PID {Pid}, container {Container}",
config.TargetPid,
config.TargetContainerId ?? "all");
var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
if (!isLinux && !config.SealedMode)
{
throw new PlatformNotSupportedException("eBPF tracing is only supported on Linux unless sealed mode is enabled");
}
// TODO: Actual eBPF program loading and uprobe attachment
// This would use libbpf or bpf2go to:
// 1. Load BPF program into kernel
// 2. Attach uprobes to target functions
// 3. Set up ringbuffer for event streaming
// 4. Handle ASLR via /proc/pid/maps
var mode = config.SealedMode ? "sealed_replay" : "live";
var capability = isLinux ? "available" : "sealed_fallback";
_logger.LogInformation(
"Starting eBPF trace collector for PID {Pid}, container {Container}. Mode={Mode}, Capability={Capability}",
config.TargetPid,
config.TargetContainerId ?? "all",
mode,
capability);
_isRunning = true;
_stats = _stats with { StartedAt = _timeProvider.GetUtcNow() };
_eventChannel = Channel.CreateUnbounded<RuntimeCallEvent>(new UnboundedChannelOptions
{
SingleWriter = true,
SingleReader = false
});
_collectorCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_stats = CreateInitialStats(mode, capability, null) with
{
StartedAt = _timeProvider.GetUtcNow(),
IsRunning = true
};
_ingestionTask = Task.Run(() => IngestionLoopAsync(config, _collectorCts.Token), _collectorCts.Token);
_logger.LogInformation("eBPF trace collector started successfully");
@@ -73,44 +86,300 @@ public sealed class EbpfTraceCollector : ITraceCollector
}
_logger.LogInformation("Stopping eBPF trace collector");
// TODO: Detach uprobes and cleanup BPF resources
_isRunning = false;
_stats = _stats with { Duration = _timeProvider.GetUtcNow() - _stats.StartedAt };
_logger.LogInformation(
"eBPF trace collector stopped. Events: {Events}, Dropped: {Dropped}",
_stats.EventsCollected,
_stats.EventsDropped);
return Task.CompletedTask;
_collectorCts?.Cancel();
return FinalizeStopAsync();
}
public async IAsyncEnumerable<RuntimeCallEvent> GetEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (!_isRunning)
var channel = _eventChannel;
if (channel is null)
{
yield break;
}
// TODO: Read events from eBPF ringbuffer
// This is a placeholder - actual implementation would:
// 1. Poll ringbuffer for events
// 2. Resolve symbols using /proc/kallsyms and binary debug info
// 3. Handle container namespace awareness
// 4. Apply rate limiting
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
yield break;
while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (channel.Reader.TryRead(out var next))
{
yield return next;
}
}
}
public TraceCollectorStats GetStatistics() => _stats;
public async ValueTask DisposeAsync()
{
await StopAsync().ConfigureAwait(false);
await StopAsync(CancellationToken.None).ConfigureAwait(false);
}
private async Task IngestionLoopAsync(TraceCollectorConfig config, CancellationToken cancellationToken)
{
var channel = _eventChannel;
if (channel is null)
{
return;
}
try
{
var events = await LoadEventsAsync(config, cancellationToken).ConfigureAwait(false);
foreach (var rawEvent in events)
{
cancellationToken.ThrowIfCancellationRequested();
if (!MatchesConfigFilters(rawEvent, config))
{
continue;
}
var normalized = await NormalizeEventAsync(rawEvent, config, cancellationToken).ConfigureAwait(false);
await channel.Writer.WriteAsync(normalized, cancellationToken).ConfigureAwait(false);
RecordCollected(normalized);
await DelayForRateLimitAsync(config, cancellationToken).ConfigureAwait(false);
}
channel.Writer.TryComplete();
}
catch (OperationCanceledException)
{
channel.Writer.TryComplete();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "eBPF ingestion loop failed");
UpdateStats(lastError: ex.Message);
channel.Writer.TryComplete(ex);
}
}
private async Task<IReadOnlyList<RuntimeCallEvent>> LoadEventsAsync(
TraceCollectorConfig config,
CancellationToken cancellationToken)
{
if (config.PreloadedEvents is { Count: > 0 } preloaded)
{
return SortEvents(preloaded);
}
if (!string.IsNullOrWhiteSpace(config.FixtureFilePath) && File.Exists(config.FixtureFilePath))
{
try
{
var bytes = await File.ReadAllBytesAsync(config.FixtureFilePath, cancellationToken).ConfigureAwait(false);
var parsed = JsonSerializer.Deserialize<IReadOnlyList<RuntimeCallEvent>>(bytes, JsonOptions)
?? Array.Empty<RuntimeCallEvent>();
return SortEvents(parsed);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load eBPF fixture events from {Path}", config.FixtureFilePath);
UpdateStats(lastError: $"fixture_load_failed:{ex.GetType().Name}");
return Array.Empty<RuntimeCallEvent>();
}
}
return Array.Empty<RuntimeCallEvent>();
}
private static IReadOnlyList<RuntimeCallEvent> SortEvents(IReadOnlyList<RuntimeCallEvent> events)
{
return events
.OrderBy(evt => evt.Timestamp)
.ThenBy(evt => evt.Pid)
.ThenBy(evt => evt.Tid)
.ThenBy(evt => evt.CallerAddress)
.ThenBy(evt => evt.CalleeAddress)
.ToArray();
}
private async Task<RuntimeCallEvent> NormalizeEventAsync(
RuntimeCallEvent input,
TraceCollectorConfig config,
CancellationToken cancellationToken)
{
var caller = string.IsNullOrWhiteSpace(input.CallerSymbol)
? null
: input.CallerSymbol.Trim();
var callee = string.IsNullOrWhiteSpace(input.CalleeSymbol)
? null
: input.CalleeSymbol.Trim();
if (config.ResolveSymbols && (caller is null || callee is null))
{
if (caller is null)
{
caller = await _symbolResolver.ResolveSymbolAsync(input.Pid, input.CallerAddress, cancellationToken).ConfigureAwait(false);
}
if (callee is null)
{
callee = await _symbolResolver.ResolveSymbolAsync(input.Pid, input.CalleeAddress, cancellationToken).ConfigureAwait(false);
}
}
return input with
{
CallerSymbol = caller ?? $"func_0x{input.CallerAddress:x}",
CalleeSymbol = callee ?? $"func_0x{input.CalleeAddress:x}",
BinaryPath = string.IsNullOrWhiteSpace(input.BinaryPath)
? $"/proc/{input.Pid}/exe"
: input.BinaryPath
};
}
private static bool MatchesConfigFilters(RuntimeCallEvent evt, TraceCollectorConfig config)
{
if (config.TargetPid != 0 && evt.Pid != config.TargetPid)
{
return false;
}
if (!string.IsNullOrWhiteSpace(config.TargetContainerId) &&
!string.Equals(evt.ContainerId, config.TargetContainerId, StringComparison.Ordinal))
{
return false;
}
if (config.SymbolPatterns is { Count: > 0 })
{
var matchesCaller = config.SymbolPatterns.Any(pattern => MatchesPattern(evt.CallerSymbol, pattern));
var matchesCallee = config.SymbolPatterns.Any(pattern => MatchesPattern(evt.CalleeSymbol, pattern));
if (!matchesCaller && !matchesCallee)
{
return false;
}
}
return true;
}
private static bool MatchesPattern(string value, string pattern)
{
if (string.IsNullOrWhiteSpace(pattern))
{
return true;
}
value ??= string.Empty;
var trimmed = pattern.Trim();
if (trimmed == "*")
{
return true;
}
if (trimmed.StartsWith('*') && trimmed.EndsWith('*') && trimmed.Length > 2)
{
return value.Contains(trimmed[1..^1], StringComparison.OrdinalIgnoreCase);
}
if (trimmed.StartsWith('*'))
{
return value.EndsWith(trimmed[1..], StringComparison.OrdinalIgnoreCase);
}
if (trimmed.EndsWith('*'))
{
return value.StartsWith(trimmed[..^1], StringComparison.OrdinalIgnoreCase);
}
return string.Equals(value, trimmed, StringComparison.OrdinalIgnoreCase);
}
private static Task DelayForRateLimitAsync(TraceCollectorConfig config, CancellationToken cancellationToken)
{
if (config.MaxEventsPerSecond <= 0 || config.MaxEventsPerSecond >= int.MaxValue)
{
return Task.CompletedTask;
}
var delay = TimeSpan.FromSeconds(1d / config.MaxEventsPerSecond);
if (delay <= TimeSpan.Zero)
{
return Task.CompletedTask;
}
return Task.Delay(delay, cancellationToken);
}
private void RecordCollected(RuntimeCallEvent evt)
{
var payloadBytes = evt.CallerSymbol.Length
+ evt.CalleeSymbol.Length
+ evt.BinaryPath.Length
+ sizeof(ulong) * 3
+ sizeof(uint) * 2;
lock (_gate)
{
_stats = _stats with
{
EventsCollected = _stats.EventsCollected + 1,
BytesProcessed = _stats.BytesProcessed + payloadBytes
};
}
}
private async Task FinalizeStopAsync()
{
try
{
if (_ingestionTask is not null)
{
await _ingestionTask.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Normal shutdown
}
finally
{
_eventChannel?.Writer.TryComplete();
_collectorCts?.Dispose();
_collectorCts = null;
_ingestionTask = null;
_isRunning = false;
lock (_gate)
{
_stats = _stats with
{
IsRunning = false,
Duration = _timeProvider.GetUtcNow() - _stats.StartedAt
};
}
}
_logger.LogInformation(
"eBPF trace collector stopped. Events: {Events}, Dropped: {Dropped}, Bytes: {Bytes}",
_stats.EventsCollected,
_stats.EventsDropped,
_stats.BytesProcessed);
}
private void UpdateStats(string? lastError = null)
{
lock (_gate)
{
_stats = _stats with { LastError = lastError };
}
}
private TraceCollectorStats CreateInitialStats(string mode, string capability, string? lastError)
{
return new TraceCollectorStats
{
EventsCollected = 0,
EventsDropped = 0,
BytesProcessed = 0,
StartedAt = _timeProvider.GetUtcNow(),
Duration = null,
IsRunning = false,
Mode = mode,
Capability = capability,
LastError = lastError
};
}
}
@@ -127,11 +396,9 @@ public interface ISymbolResolver
/// </summary>
public sealed class LinuxSymbolResolver : ISymbolResolver
{
private readonly ILogger<LinuxSymbolResolver> _logger;
public LinuxSymbolResolver(ILogger<LinuxSymbolResolver> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(logger);
}
public async Task<string> ResolveSymbolAsync(
@@ -139,13 +406,8 @@ public sealed class LinuxSymbolResolver : ISymbolResolver
ulong address,
CancellationToken cancellationToken = default)
{
// TODO: Actual symbol resolution:
// 1. Read /proc/{pid}/maps to find binary containing address
// 2. Adjust for ASLR offset
// 3. Use libdwarf or addr2line to resolve symbol
// 4. Cache results for performance
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
await Task.Yield();
cancellationToken.ThrowIfCancellationRequested();
return $"func_0x{address:x}";
}
}

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Threading.Channels;
using System.Runtime.InteropServices;
namespace StellaOps.Scanner.Runtime.Etw;
@@ -10,8 +12,16 @@ public sealed class EtwTraceCollector : ITraceCollector
{
private readonly ILogger<EtwTraceCollector> _logger;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
private bool _isRunning;
private Channel<RuntimeCallEvent>? _eventChannel;
private CancellationTokenSource? _collectorCts;
private Task? _ingestionTask;
private TraceCollectorStats _stats;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public EtwTraceCollector(
ILogger<EtwTraceCollector> logger,
@@ -19,43 +29,45 @@ public sealed class EtwTraceCollector : ITraceCollector
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_stats = new TraceCollectorStats
{
EventsCollected = 0,
EventsDropped = 0,
BytesProcessed = 0,
StartedAt = _timeProvider.GetUtcNow()
};
_stats = CreateInitialStats("disabled", "unsupported", null);
}
public Task StartAsync(TraceCollectorConfig config, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(config);
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
throw new PlatformNotSupportedException("ETW tracing is only supported on Windows");
}
if (_isRunning)
{
throw new InvalidOperationException("Collector is already running");
}
_logger.LogInformation(
"Starting ETW trace collector for PID {Pid}",
config.TargetPid);
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
if (!isWindows && !config.SealedMode)
{
throw new PlatformNotSupportedException("ETW tracing is only supported on Windows unless sealed mode is enabled");
}
// TODO: Actual ETW session setup
// This would use TraceEvent or Microsoft.Diagnostics.Tracing.TraceEvent to:
// 1. Create ETW session
// 2. Subscribe to Microsoft-Windows-DotNETRuntime provider
// 3. Subscribe to native call events
// 4. Enable stack walking
// 5. Filter by process ID
var mode = config.SealedMode ? "sealed_replay" : "live";
var capability = isWindows ? "available" : "sealed_fallback";
_logger.LogInformation(
"Starting ETW trace collector for PID {Pid}. Mode={Mode}, Capability={Capability}",
config.TargetPid,
mode,
capability);
_isRunning = true;
_stats = _stats with { StartedAt = _timeProvider.GetUtcNow() };
_eventChannel = Channel.CreateUnbounded<RuntimeCallEvent>(new UnboundedChannelOptions
{
SingleWriter = true,
SingleReader = false
});
_collectorCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_stats = CreateInitialStats(mode, capability, null) with
{
StartedAt = _timeProvider.GetUtcNow(),
IsRunning = true
};
_ingestionTask = Task.Run(() => IngestionLoopAsync(config, _collectorCts.Token), _collectorCts.Token);
_logger.LogInformation("ETW trace collector started successfully");
@@ -70,44 +82,227 @@ public sealed class EtwTraceCollector : ITraceCollector
}
_logger.LogInformation("Stopping ETW trace collector");
// TODO: Stop ETW session and cleanup
_isRunning = false;
_stats = _stats with { Duration = _timeProvider.GetUtcNow() - _stats.StartedAt };
_logger.LogInformation(
"ETW trace collector stopped. Events: {Events}, Dropped: {Dropped}",
_stats.EventsCollected,
_stats.EventsDropped);
return Task.CompletedTask;
_collectorCts?.Cancel();
return FinalizeStopAsync();
}
public async IAsyncEnumerable<RuntimeCallEvent> GetEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (!_isRunning)
var channel = _eventChannel;
if (channel is null)
{
yield break;
}
// TODO: Process ETW events
// This is a placeholder - actual implementation would:
// 1. Subscribe to ETW event stream
// 2. Process CLR and native method events
// 3. Resolve symbols using DbgHelp
// 4. Correlate stack traces
// 5. Apply rate limiting
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
yield break;
while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (channel.Reader.TryRead(out var next))
{
yield return next;
}
}
}
public TraceCollectorStats GetStatistics() => _stats;
public async ValueTask DisposeAsync()
{
await StopAsync().ConfigureAwait(false);
await StopAsync(CancellationToken.None).ConfigureAwait(false);
}
private async Task IngestionLoopAsync(TraceCollectorConfig config, CancellationToken cancellationToken)
{
var channel = _eventChannel;
if (channel is null)
{
return;
}
try
{
var events = await LoadEventsAsync(config, cancellationToken).ConfigureAwait(false);
foreach (var evt in events)
{
cancellationToken.ThrowIfCancellationRequested();
if (!MatchesConfigFilters(evt, config))
{
continue;
}
var normalized = evt with
{
BinaryPath = string.IsNullOrWhiteSpace(evt.BinaryPath)
? "unknown-binary"
: evt.BinaryPath
};
await channel.Writer.WriteAsync(normalized, cancellationToken).ConfigureAwait(false);
RecordCollected(normalized);
await DelayForRateLimitAsync(config, cancellationToken).ConfigureAwait(false);
}
channel.Writer.TryComplete();
}
catch (OperationCanceledException)
{
channel.Writer.TryComplete();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ETW ingestion loop failed");
UpdateStats(lastError: ex.Message);
channel.Writer.TryComplete(ex);
}
}
private async Task<IReadOnlyList<RuntimeCallEvent>> LoadEventsAsync(
TraceCollectorConfig config,
CancellationToken cancellationToken)
{
if (config.PreloadedEvents is { Count: > 0 } preloaded)
{
return SortEvents(preloaded);
}
if (!string.IsNullOrWhiteSpace(config.FixtureFilePath) && File.Exists(config.FixtureFilePath))
{
try
{
var bytes = await File.ReadAllBytesAsync(config.FixtureFilePath, cancellationToken).ConfigureAwait(false);
var parsed = JsonSerializer.Deserialize<IReadOnlyList<RuntimeCallEvent>>(bytes, JsonOptions)
?? Array.Empty<RuntimeCallEvent>();
return SortEvents(parsed);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load ETW fixture events from {Path}", config.FixtureFilePath);
UpdateStats(lastError: $"fixture_load_failed:{ex.GetType().Name}");
return Array.Empty<RuntimeCallEvent>();
}
}
return Array.Empty<RuntimeCallEvent>();
}
private static IReadOnlyList<RuntimeCallEvent> SortEvents(IReadOnlyList<RuntimeCallEvent> events)
{
return events
.OrderBy(evt => evt.Timestamp)
.ThenBy(evt => evt.Pid)
.ThenBy(evt => evt.Tid)
.ThenBy(evt => evt.CallerAddress)
.ThenBy(evt => evt.CalleeAddress)
.ToArray();
}
private static bool MatchesConfigFilters(RuntimeCallEvent evt, TraceCollectorConfig config)
{
if (config.TargetPid != 0 && evt.Pid != config.TargetPid)
{
return false;
}
if (!string.IsNullOrWhiteSpace(config.TargetContainerId) &&
!string.Equals(evt.ContainerId, config.TargetContainerId, StringComparison.Ordinal))
{
return false;
}
return true;
}
private static Task DelayForRateLimitAsync(TraceCollectorConfig config, CancellationToken cancellationToken)
{
if (config.MaxEventsPerSecond <= 0 || config.MaxEventsPerSecond >= int.MaxValue)
{
return Task.CompletedTask;
}
var delay = TimeSpan.FromSeconds(1d / config.MaxEventsPerSecond);
if (delay <= TimeSpan.Zero)
{
return Task.CompletedTask;
}
return Task.Delay(delay, cancellationToken);
}
private void RecordCollected(RuntimeCallEvent evt)
{
var payloadBytes = evt.CallerSymbol.Length
+ evt.CalleeSymbol.Length
+ evt.BinaryPath.Length
+ sizeof(ulong) * 3
+ sizeof(uint) * 2;
lock (_gate)
{
_stats = _stats with
{
EventsCollected = _stats.EventsCollected + 1,
BytesProcessed = _stats.BytesProcessed + payloadBytes
};
}
}
private async Task FinalizeStopAsync()
{
try
{
if (_ingestionTask is not null)
{
await _ingestionTask.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Normal shutdown
}
finally
{
_eventChannel?.Writer.TryComplete();
_collectorCts?.Dispose();
_collectorCts = null;
_ingestionTask = null;
_isRunning = false;
lock (_gate)
{
_stats = _stats with
{
IsRunning = false,
Duration = _timeProvider.GetUtcNow() - _stats.StartedAt
};
}
}
_logger.LogInformation(
"ETW trace collector stopped. Events: {Events}, Dropped: {Dropped}, Bytes: {Bytes}",
_stats.EventsCollected,
_stats.EventsDropped,
_stats.BytesProcessed);
}
private void UpdateStats(string? lastError = null)
{
lock (_gate)
{
_stats = _stats with { LastError = lastError };
}
}
private TraceCollectorStats CreateInitialStats(string mode, string capability, string? lastError)
{
return new TraceCollectorStats
{
EventsCollected = 0,
EventsDropped = 0,
BytesProcessed = 0,
StartedAt = _timeProvider.GetUtcNow(),
Duration = null,
IsRunning = false,
Mode = mode,
Capability = capability,
LastError = lastError
};
}
}

View File

@@ -95,6 +95,26 @@ public sealed record TraceCollectorConfig
/// Enable stack trace capture.
/// </summary>
public bool CaptureStackTraces { get; init; }
/// <summary>
/// Sealed/offline mode: replay deterministic fixture events instead of host tracing APIs.
/// </summary>
public bool SealedMode { get; init; }
/// <summary>
/// Optional JSON fixture file with <see cref="RuntimeCallEvent"/> entries.
/// </summary>
public string? FixtureFilePath { get; init; }
/// <summary>
/// Optional in-memory deterministic events used for tests and offline replay.
/// </summary>
public IReadOnlyList<RuntimeCallEvent>? PreloadedEvents { get; init; }
/// <summary>
/// Resolve missing symbol names via collector-specific symbol resolvers.
/// </summary>
public bool ResolveSymbols { get; init; } = true;
}
/// <summary>
@@ -132,5 +152,9 @@ public sealed record TraceCollectorStats
public required long EventsDropped { get; init; }
public required long BytesProcessed { get; init; }
public required DateTimeOffset StartedAt { get; init; }
public required bool IsRunning { get; init; }
public required string Mode { get; init; }
public required string Capability { get; init; }
public string? LastError { get; init; }
public TimeSpan? Duration { get; init; }
}

View File

@@ -1,6 +1,9 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Cache.Abstractions;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Runtime.Ingestion;
@@ -9,9 +12,16 @@ namespace StellaOps.Scanner.Runtime.Ingestion;
/// </summary>
public sealed class TraceIngestionService : ITraceIngestionService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IFileContentAddressableStore _cas;
private readonly ILogger<TraceIngestionService> _logger;
private readonly TimeProvider _timeProvider;
private readonly object _scanIndexSync = new();
private readonly Dictionary<string, byte[]> _payloadByTraceId = new(StringComparer.Ordinal);
private readonly Dictionary<string, NormalizedTrace> _traceById = new(StringComparer.Ordinal);
private readonly Dictionary<string, ImmutableSortedSet<string>> _traceIdsByScan = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _casDigestByTraceId = new(StringComparer.Ordinal);
public TraceIngestionService(
IFileContentAddressableStore cas,
@@ -82,13 +92,16 @@ public sealed class TraceIngestionService : ITraceIngestionService
.ThenBy(e => e.To)
.ToList();
var duration = (lastEvent ?? _timeProvider.GetUtcNow()) - (firstEvent ?? _timeProvider.GetUtcNow());
var collectedAt = _timeProvider.GetUtcNow();
var duration = (lastEvent ?? collectedAt) - (firstEvent ?? collectedAt);
var normalizedScanId = scanId.Trim();
var traceId = GenerateTraceId(normalizedScanId, edges, pid ?? 0, binaryPath ?? "unknown", eventCount, duration);
var trace = new NormalizedTrace
{
TraceId = GenerateTraceId(scanId, eventCount),
ScanId = scanId,
CollectedAt = _timeProvider.GetUtcNow(),
TraceId = traceId,
ScanId = normalizedScanId,
CollectedAt = collectedAt,
Edges = edges,
Metadata = new TraceMetadata
{
@@ -115,16 +128,32 @@ public sealed class TraceIngestionService : ITraceIngestionService
{
ArgumentNullException.ThrowIfNull(trace);
var json = System.Text.Json.JsonSerializer.Serialize(trace);
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
var bytes = JsonSerializer.SerializeToUtf8Bytes(trace, JsonOptions);
var digest = ComputeSha256(bytes);
await using var stream = new MemoryStream(bytes, writable: false);
var casKey = $"trace_{trace.TraceId}";
await _cas.PutAsync(new FileCasPutRequest(casKey, stream, leaveOpen: false), cancellationToken)
await _cas.PutAsync(new FileCasPutRequest(digest, stream, leaveOpen: false), cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Stored trace {TraceId} in CAS with key {CasKey}", trace.TraceId, casKey);
lock (_scanIndexSync)
{
_payloadByTraceId[trace.TraceId] = bytes;
_traceById[trace.TraceId] = trace;
_casDigestByTraceId[trace.TraceId] = digest;
if (!_traceIdsByScan.TryGetValue(trace.ScanId, out var existing))
{
existing = ImmutableSortedSet<string>.Empty;
}
_traceIdsByScan[trace.ScanId] = existing.Add(trace.TraceId);
}
_logger.LogInformation(
"Stored trace {TraceId} in CAS digest {Digest} and indexed for scan {ScanId}",
trace.TraceId,
digest,
trace.ScanId);
return trace.TraceId;
}
@@ -135,47 +164,119 @@ public sealed class TraceIngestionService : ITraceIngestionService
{
ArgumentException.ThrowIfNullOrWhiteSpace(traceId);
var casKey = $"trace_{traceId}";
var normalizedTraceId = traceId.Trim();
lock (_scanIndexSync)
{
if (_traceById.TryGetValue(normalizedTraceId, out var cached))
{
return cached;
}
if (_payloadByTraceId.TryGetValue(normalizedTraceId, out var payload))
{
var hydrated = JsonSerializer.Deserialize<NormalizedTrace>(payload, JsonOptions);
if (hydrated is not null)
{
_traceById[normalizedTraceId] = hydrated;
return hydrated;
}
}
}
// We can verify CAS presence via TryGetAsync, but payload bytes are not available
// through CAS abstractions in this module.
string? digest;
lock (_scanIndexSync)
{
_casDigestByTraceId.TryGetValue(normalizedTraceId, out digest);
}
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
try
{
var bytes = await _cas.GetAsync(new FileCasGetRequest(casKey), cancellationToken)
.ConfigureAwait(false);
if (bytes is null)
var entry = await _cas.TryGetAsync(digest, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
return null;
}
var trace = System.Text.Json.JsonSerializer.Deserialize<NormalizedTrace>(bytes);
return trace;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving trace {TraceId}", traceId);
return null;
_logger.LogWarning(ex, "CAS lookup failed for trace {TraceId}", normalizedTraceId);
}
return null;
}
public async Task<IReadOnlyList<NormalizedTrace>> GetTracesForScanAsync(
string scanId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
var normalizedScanId = scanId.Trim();
ImmutableSortedSet<string> traceIds;
lock (_scanIndexSync)
{
if (!_traceIdsByScan.TryGetValue(normalizedScanId, out traceIds!))
{
return Array.Empty<NormalizedTrace>();
}
}
// TODO: Implement scan-to-trace index
// For now, return empty list
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
return Array.Empty<NormalizedTrace>();
var traces = new List<NormalizedTrace>(traceIds.Count);
foreach (var traceId in traceIds)
{
cancellationToken.ThrowIfCancellationRequested();
var trace = await GetTraceAsync(traceId, cancellationToken).ConfigureAwait(false);
if (trace is not null)
{
traces.Add(trace);
}
}
return traces
.OrderBy(t => t.TraceId, StringComparer.Ordinal)
.ToList();
}
private string GenerateTraceId(string scanId, long eventCount)
private static string GenerateTraceId(
string scanId,
IReadOnlyList<RuntimeCallEdge> edges,
uint processId,
string binaryPath,
long eventCount,
TimeSpan duration)
{
var input = $"{scanId}|{eventCount}|{_timeProvider.GetUtcNow().Ticks}";
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
var builder = new StringBuilder();
builder.Append(scanId).Append('|')
.Append(processId).Append('|')
.Append(binaryPath).Append('|')
.Append(eventCount).Append('|')
.Append(duration.Ticks);
foreach (var edge in edges.OrderBy(e => e.From, StringComparer.Ordinal).ThenBy(e => e.To, StringComparer.Ordinal))
{
builder.Append('|')
.Append(edge.From).Append("->").Append(edge.To).Append(':')
.Append(edge.ObservationCount).Append(':')
.Append(edge.FirstObserved.UtcTicks).Append(':')
.Append(edge.LastObserved.UtcTicks);
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return $"trace_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}
private static string ComputeSha256(byte[] bytes)
{
var hash = SHA256.HashData(bytes);
return Convert.ToHexStringLower(hash);
}
private sealed class RuntimeCallEdgeBuilder
{
public required string From { get; init; }

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="ITraceCollector.cs" />
<Compile Include="Ebpf\*.cs" />
<Compile Include="Etw\*.cs" />
<Compile Include="Ingestion\*.cs" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,8 @@
using Microsoft.Extensions.Logging;
using StellaOps.Replay.Core;
using StellaOps.Scanner.ProofSpine;
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
@@ -31,6 +34,25 @@ public sealed record SlicePullOptions
/// Request timeout. Default: 30 seconds.
/// </summary>
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether to attempt deterministic fallback discovery when OCI referrers API is unavailable.
/// Default: true.
/// </summary>
public bool EnableReferrersFallback { get; init; } = true;
/// <summary>
/// Candidate tag prefixes for deterministic fallback referrer discovery.
/// </summary>
public IReadOnlyList<string> ReferrerTagPrefixes { get; init; } = new[]
{
"att-",
"ref-",
"sha256-",
"sbom-",
"vex-",
"proof-"
};
}
/// <summary>
@@ -51,6 +73,32 @@ public sealed record SlicePullResult
public bool SignatureVerified { get; init; }
}
/// <summary>
/// Capability status for OCI referrer discovery.
/// </summary>
public enum OciReferrersCapability
{
Supported,
Unsupported,
Unavailable
}
/// <summary>
/// Result for referrer queries with capability and fallback metadata.
/// </summary>
public sealed record OciReferrersQueryResult
{
public required IReadOnlyList<OciReferrer> Referrers { get; init; }
public required OciReferrersCapability Capability { get; init; }
public bool FallbackUsed { get; init; }
public string? FailureReason { get; init; }
public int? StatusCode { get; init; }
}
/// <summary>
/// Service for pulling reachability slices from OCI registries.
/// Supports content-addressed retrieval and DSSE signature verification.
@@ -61,6 +109,7 @@ public sealed class SlicePullService : IDisposable
private readonly HttpClient _httpClient;
private readonly OciRegistryAuthorization _authorization;
private readonly SlicePullOptions _options;
private readonly IDsseSigningService? _dsseSigningService;
private readonly ILogger<SlicePullService> _logger;
private readonly TimeProvider _timeProvider;
private readonly Dictionary<string, CachedSlice> _cache = new(StringComparer.Ordinal);
@@ -72,12 +121,14 @@ public sealed class SlicePullService : IDisposable
HttpClient httpClient,
OciRegistryAuthorization authorization,
SlicePullOptions? options = null,
IDsseSigningService? dsseSigningService = null,
ILogger<SlicePullService>? logger = null,
TimeProvider? timeProvider = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
_options = options ?? new SlicePullOptions();
_dsseSigningService = dsseSigningService;
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SlicePullService>.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
_httpClient.Timeout = _options.RequestTimeout;
@@ -330,6 +381,20 @@ public sealed class SlicePullService : IDisposable
string digest,
string? artifactType = null,
CancellationToken cancellationToken = default)
{
var result = await ListReferrersWithCapabilityAsync(reference, digest, artifactType, cancellationToken)
.ConfigureAwait(false);
return result.Referrers;
}
/// <summary>
/// List referrers and return capability/fallback metadata.
/// </summary>
public async Task<OciReferrersQueryResult> ListReferrersWithCapabilityAsync(
OciImageReference reference,
string digest,
string? artifactType = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(reference);
ArgumentException.ThrowIfNullOrWhiteSpace(digest);
@@ -349,21 +414,70 @@ public sealed class SlicePullService : IDisposable
using var response = await _httpClient.SendAsync(request, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
if (response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to list referrers for {Digest}: {Status}", digest, response.StatusCode);
return Array.Empty<OciReferrer>();
var index = await response.Content.ReadFromJsonAsync<OciReferrersIndex>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
var referrers = (IReadOnlyList<OciReferrer>?)index?.Manifests ?? Array.Empty<OciReferrer>();
return new OciReferrersQueryResult
{
Referrers = referrers,
Capability = OciReferrersCapability.Supported,
FallbackUsed = false
};
}
var index = await response.Content.ReadFromJsonAsync<OciReferrersIndex>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (_options.EnableReferrersFallback && IsReferrersUnsupportedStatus(response.StatusCode))
{
var fallback = await ListFallbackTagReferrersAsync(reference, digest, artifactType, cancellationToken)
.ConfigureAwait(false);
return (IReadOnlyList<OciReferrer>?)index?.Manifests ?? Array.Empty<OciReferrer>();
_logger.LogWarning(
"OCI referrers API unsupported for {Registry}/{Repository}@{Digest} (status {StatusCode}); fallback tags used={FallbackUsed}, discovered={Count}",
reference.Registry,
reference.Repository,
digest,
(int)response.StatusCode,
true,
fallback.Count);
return new OciReferrersQueryResult
{
Referrers = fallback,
Capability = OciReferrersCapability.Unsupported,
FallbackUsed = true,
StatusCode = (int)response.StatusCode,
FailureReason = $"referrers_unsupported:{response.StatusCode}"
};
}
_logger.LogWarning(
"Failed to list referrers for {Registry}/{Repository}@{Digest} with status {StatusCode}",
reference.Registry,
reference.Repository,
digest,
response.StatusCode);
return new OciReferrersQueryResult
{
Referrers = Array.Empty<OciReferrer>(),
Capability = OciReferrersCapability.Unavailable,
FallbackUsed = false,
StatusCode = (int)response.StatusCode,
FailureReason = $"referrers_error:{response.StatusCode}"
};
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
_logger.LogError(ex, "Failed to list referrers for {Digest}", digest);
return Array.Empty<OciReferrer>();
_logger.LogError(ex, "Failed to list referrers for {Registry}/{Repository}@{Digest}", reference.Registry, reference.Repository, digest);
return new OciReferrersQueryResult
{
Referrers = Array.Empty<OciReferrer>(),
Capability = OciReferrersCapability.Unavailable,
FallbackUsed = false,
FailureReason = ex.GetType().Name
};
}
}
@@ -372,7 +486,114 @@ public sealed class SlicePullService : IDisposable
// HttpClient typically managed externally
}
private async Task<(byte[]? Envelope, bool Verified)> FetchAndVerifyDsseAsync(
private async Task<IReadOnlyList<OciReferrer>> ListFallbackTagReferrersAsync(
OciImageReference reference,
string digest,
string? artifactType,
CancellationToken cancellationToken)
{
var tagsUrl = $"https://{reference.Registry}/v2/{reference.Repository}/tags/list";
using var tagsRequest = new HttpRequestMessage(HttpMethod.Get, tagsUrl);
await _authorization.AuthorizeRequestAsync(tagsRequest, reference, cancellationToken).ConfigureAwait(false);
using var tagsResponse = await _httpClient.SendAsync(tagsRequest, cancellationToken).ConfigureAwait(false);
if (!tagsResponse.IsSuccessStatusCode)
{
_logger.LogWarning(
"Fallback tag discovery failed for {Registry}/{Repository} with status {StatusCode}",
reference.Registry,
reference.Repository,
tagsResponse.StatusCode);
return Array.Empty<OciReferrer>();
}
var tagList = await tagsResponse.Content.ReadFromJsonAsync<OciTagsList>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
var tags = tagList?.Tags ?? Array.Empty<string>();
if (tags.Count == 0)
{
return Array.Empty<OciReferrer>();
}
var results = new List<OciReferrer>();
foreach (var tag in tags.OrderBy(static tag => tag, StringComparer.Ordinal))
{
if (!LooksLikeFallbackReferrerTag(tag, artifactType))
{
continue;
}
var manifestUrl = $"https://{reference.Registry}/v2/{reference.Repository}/manifests/{Uri.EscapeDataString(tag)}";
using var manifestRequest = new HttpRequestMessage(HttpMethod.Get, manifestUrl);
manifestRequest.Headers.Accept.ParseAdd(OciMediaTypes.ImageManifest);
await _authorization.AuthorizeRequestAsync(manifestRequest, reference, cancellationToken).ConfigureAwait(false);
using var manifestResponse = await _httpClient.SendAsync(manifestRequest, cancellationToken).ConfigureAwait(false);
if (!manifestResponse.IsSuccessStatusCode)
{
continue;
}
var manifest = await manifestResponse.Content.ReadFromJsonAsync<OciManifest>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (!string.Equals(manifest?.Subject?.Digest, digest, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var descriptorDigest = manifestResponse.Headers.TryGetValues("Docker-Content-Digest", out var digestValues)
? digestValues.FirstOrDefault()
: null;
results.Add(new OciReferrer
{
MediaType = manifest?.MediaType ?? OciMediaTypes.ImageManifest,
Digest = descriptorDigest,
ArtifactType = manifest?.ArtifactType,
Size = manifestResponse.Content.Headers.ContentLength ?? 0,
Annotations = manifest?.Annotations
});
}
return results;
}
private static bool IsReferrersUnsupportedStatus(HttpStatusCode statusCode)
{
return statusCode is HttpStatusCode.NotFound
or HttpStatusCode.MethodNotAllowed
or HttpStatusCode.NotAcceptable
or HttpStatusCode.BadRequest;
}
private bool LooksLikeFallbackReferrerTag(string tag, string? artifactType)
{
if (string.IsNullOrWhiteSpace(tag))
{
return false;
}
if (_options.ReferrerTagPrefixes.Count > 0 &&
_options.ReferrerTagPrefixes.Any(prefix => tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
if (string.IsNullOrWhiteSpace(artifactType))
{
return false;
}
var token = artifactType
.Replace("application/vnd.", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace('+', '-')
.Replace('/', '-')
.Replace('.', '-')
.ToLowerInvariant();
return tag.Contains(token, StringComparison.OrdinalIgnoreCase);
}
private async Task<DsseFetchVerificationResult> FetchAndVerifyDsseAsync(
OciImageReference reference,
string digest,
byte[] payload,
@@ -390,22 +611,71 @@ public sealed class SlicePullService : IDisposable
if (!response.IsSuccessStatusCode)
{
return (null, false);
return DsseFetchVerificationResult.Failed(
envelope: null,
failureReason: $"dsse_fetch_failed:{response.StatusCode}");
}
var envelopeBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken)
.ConfigureAwait(false);
// TODO: Actual DSSE verification using configured trust roots
// For now, just return the envelope
_logger.LogDebug("DSSE envelope fetched, verification pending trust root configuration");
DsseEnvelope? envelope;
try
{
envelope = JsonSerializer.Deserialize<DsseEnvelope>(envelopeBytes, JsonOptions);
}
catch (JsonException)
{
return DsseFetchVerificationResult.Failed(envelopeBytes, "dsse_invalid_json");
}
return (envelopeBytes, false);
if (envelope is null)
{
return DsseFetchVerificationResult.Failed(envelopeBytes, "dsse_invalid_envelope");
}
if (!TryDecodeBase64(envelope.Payload, out var envelopePayload))
{
return DsseFetchVerificationResult.Failed(envelopeBytes, "dsse_payload_not_base64");
}
if (!payload.AsSpan().SequenceEqual(envelopePayload))
{
return DsseFetchVerificationResult.Failed(envelopeBytes, "dsse_payload_mismatch");
}
if (_dsseSigningService is null)
{
_logger.LogWarning("DSSE envelope fetched but verification service is not configured.");
return DsseFetchVerificationResult.Failed(envelopeBytes, "dsse_verifier_not_configured");
}
var verification = await _dsseSigningService.VerifyAsync(envelope, cancellationToken).ConfigureAwait(false);
if (!verification.IsValid)
{
return DsseFetchVerificationResult.Failed(envelopeBytes, verification.FailureReason ?? "dsse_signature_invalid");
}
return DsseFetchVerificationResult.Success(envelopeBytes);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch/verify DSSE envelope");
return (null, false);
return DsseFetchVerificationResult.Failed(null, ex.GetType().Name);
}
}
private static bool TryDecodeBase64(string value, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(value);
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
@@ -455,6 +725,8 @@ public sealed class SlicePullService : IDisposable
public string? MediaType { get; init; }
public string? ArtifactType { get; init; }
public OciDescriptor? Config { get; init; }
public OciDescriptor? Subject { get; init; }
public Dictionary<string, string>? Annotations { get; init; }
public List<OciDescriptor>? Layers { get; init; }
}
@@ -471,6 +743,28 @@ public sealed class SlicePullService : IDisposable
public string? MediaType { get; init; }
public List<OciReferrer>? Manifests { get; init; }
}
private sealed record OciTagsList
{
public string? Name { get; init; }
public IReadOnlyList<string>? Tags { get; init; }
}
private sealed record DsseFetchVerificationResult
{
public byte[]? Envelope { get; init; }
public bool Verified { get; init; }
public string? FailureReason { get; init; }
public static DsseFetchVerificationResult Success(byte[] envelope)
=> new() { Envelope = envelope, Verified = true };
public static DsseFetchVerificationResult Failed(byte[]? envelope, string failureReason)
=> new() { Envelope = envelope, Verified = false, FailureReason = failureReason };
}
}
/// <summary>

View File

@@ -10,6 +10,7 @@ namespace StellaOps.Scanner.Storage.EfCore.Context;
public partial class ScannerDbContext : DbContext
{
private readonly string _schemaName;
internal string SchemaName => _schemaName;
public ScannerDbContext(DbContextOptions<ScannerDbContext> options, string? schemaName = null)
: base(options)

View File

@@ -13,6 +13,8 @@ namespace StellaOps.Scanner.Storage.Postgres;
public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDriftResultRepository
{
private const string UndefinedTableSqlState = "42P01";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false
@@ -37,9 +39,179 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
{
ArgumentNullException.ThrowIfNull(result);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
Exception? lastFailure = null;
foreach (var schema in GetSchemaCandidates())
{
try
{
await StoreForSchemaAsync(
result,
tenantScope.TenantContext,
tenantScope.TenantId,
schema,
ct)
.ConfigureAwait(false);
return;
}
catch (PostgresException ex) when (IsUndefinedTable(ex))
{
lastFailure = ex;
_logger.LogWarning(
ex,
"Drift tables missing in schema {Schema}; trying fallback schema for base={BaseScanId} head={HeadScanId}.",
schema,
result.BaseScanId,
result.HeadScanId);
}
}
throw lastFailure ?? new InvalidOperationException("Unable to store reachability drift result in any configured schema.");
}
public async Task<ReachabilityDriftResult?> TryGetLatestForHeadAsync(string headScanId, string language, CancellationToken ct = default, string? tenantId = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(headScanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var trimmedHead = headScanId.Trim();
var trimmedLang = language.Trim();
foreach (var schema in GetSchemaCandidates())
{
try
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, schema);
var entity = await dbContext.ReachabilityDriftResults
.Include(e => e.DriftedSinks)
.Where(e => e.TenantId == tenantScope.TenantId && e.HeadScanId == trimmedHead && e.Language == trimmedLang)
.OrderByDescending(e => e.DetectedAt)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
if (entity is not null)
{
return MapEntityToResult(entity);
}
}
catch (PostgresException ex) when (IsUndefinedTable(ex))
{
_logger.LogWarning(ex, "Drift table missing in schema {Schema} during TryGetLatestForHeadAsync; trying fallback.", schema);
}
}
return null;
}
public async Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null)
{
var tenantScope = ScannerTenantScope.Resolve(tenantId);
foreach (var schema in GetSchemaCandidates())
{
try
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, schema);
var entity = await dbContext.ReachabilityDriftResults
.Include(e => e.DriftedSinks)
.FirstOrDefaultAsync(e => e.TenantId == tenantScope.TenantId && e.Id == driftId, ct)
.ConfigureAwait(false);
if (entity is not null)
{
return MapEntityToResult(entity);
}
}
catch (PostgresException ex) when (IsUndefinedTable(ex))
{
_logger.LogWarning(ex, "Drift table missing in schema {Schema} during TryGetByIdAsync; trying fallback.", schema);
}
}
return null;
}
public async Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null)
{
var tenantScope = ScannerTenantScope.Resolve(tenantId);
foreach (var schema in GetSchemaCandidates())
{
try
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, schema);
return await dbContext.ReachabilityDriftResults
.AnyAsync(e => e.TenantId == tenantScope.TenantId && e.Id == driftId, ct)
.ConfigureAwait(false);
}
catch (PostgresException ex) when (IsUndefinedTable(ex))
{
_logger.LogWarning(ex, "Drift table missing in schema {Schema} during ExistsAsync; trying fallback.", schema);
}
}
return false;
}
public async Task<IReadOnlyList<DriftedSink>> ListSinksAsync(
Guid driftId,
DriftDirection direction,
int offset,
int limit,
CancellationToken ct = default,
string? tenantId = null)
{
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset));
}
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit));
}
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var directionValue = ToDbValue(direction);
foreach (var schema in GetSchemaCandidates())
{
try
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, schema);
var entities = await dbContext.DriftedSinks
.Where(e => e.TenantId == tenantScope.TenantId && e.DriftResultId == driftId && e.Direction == directionValue)
.OrderBy(e => e.SinkNodeId)
.Skip(offset)
.Take(limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(e => MapSinkEntityToModel(e, direction)).ToList();
}
catch (PostgresException ex) when (IsUndefinedTable(ex))
{
_logger.LogWarning(ex, "Drift sink table missing in schema {Schema} during ListSinksAsync; trying fallback.", schema);
}
}
return [];
}
private async Task StoreForSchemaAsync(
ReachabilityDriftResult result,
string tenantContext,
Guid tenantId,
string schemaName,
CancellationToken ct)
{
var driftResultsTable = $"{schemaName}.reachability_drift_results";
var driftedSinksTable = $"{schemaName}.drifted_sinks";
var insertResultSql = $"""
INSERT INTO {DriftResultsTable} (
INSERT INTO {driftResultsTable} (
id, tenant_id, base_scan_id, head_scan_id, language,
newly_reachable_count, newly_unreachable_count, detected_at, result_digest
) VALUES (
@@ -53,12 +225,12 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
""";
var deleteSinksSql = $"""
DELETE FROM {DriftedSinksTable}
DELETE FROM {driftedSinksTable}
WHERE tenant_id = $1 AND drift_result_id = $2
""";
var insertSinkSql = $"""
INSERT INTO {DriftedSinksTable} (
INSERT INTO {driftedSinksTable} (
id, tenant_id, drift_result_id, sink_node_id, symbol,
sink_category, direction, cause_kind, cause_description,
cause_symbol, cause_file, cause_line, code_change_id,
@@ -80,15 +252,14 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
associated_vulns = EXCLUDED.associated_vulns
""";
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var connection = await _dataSource.OpenConnectionAsync(tenantContext, ct).ConfigureAwait(false);
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
// Insert drift result header and get the returned id
await using var insertCmd = new NpgsqlCommand(insertResultSql, connection, transaction);
insertCmd.Parameters.AddWithValue(result.Id);
insertCmd.Parameters.AddWithValue(tenantScope.TenantId);
insertCmd.Parameters.AddWithValue(tenantId);
insertCmd.Parameters.AddWithValue(result.BaseScanId.Trim());
insertCmd.Parameters.AddWithValue(result.HeadScanId.Trim());
insertCmd.Parameters.AddWithValue(result.Language.Trim());
@@ -100,15 +271,13 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
var driftIdObj = await insertCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
var driftId = (Guid)driftIdObj!;
// Delete existing sinks for this drift result
await using var deleteCmd = new NpgsqlCommand(deleteSinksSql, connection, transaction);
deleteCmd.Parameters.AddWithValue(tenantScope.TenantId);
deleteCmd.Parameters.AddWithValue(tenantId);
deleteCmd.Parameters.AddWithValue(driftId);
await deleteCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
// Insert all sink rows
var sinks = EnumerateSinkParams(driftId, tenantScope.TenantId, result.NewlyReachable, DriftDirection.BecameReachable)
.Concat(EnumerateSinkParams(driftId, tenantScope.TenantId, result.NewlyUnreachable, DriftDirection.BecameUnreachable))
var sinks = EnumerateSinkParams(driftId, tenantId, result.NewlyReachable, DriftDirection.BecameReachable)
.Concat(EnumerateSinkParams(driftId, tenantId, result.NewlyUnreachable, DriftDirection.BecameUnreachable))
.ToList();
foreach (var sink in sinks)
@@ -134,104 +303,41 @@ public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDri
}
await transaction.CommitAsync(ct).ConfigureAwait(false);
_logger.LogDebug(
"Stored drift result drift={DriftId} base={BaseScanId} head={HeadScanId} lang={Language}",
"Stored drift result drift={DriftId} base={BaseScanId} head={HeadScanId} lang={Language} schema={Schema}",
driftId,
result.BaseScanId,
result.HeadScanId,
result.Language);
result.Language,
schemaName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to store drift result base={BaseScanId} head={HeadScanId}", result.BaseScanId, result.HeadScanId);
_logger.LogError(
ex,
"Failed to store drift result base={BaseScanId} head={HeadScanId} schema={Schema}",
result.BaseScanId,
result.HeadScanId,
schemaName);
await transaction.RollbackAsync(ct).ConfigureAwait(false);
throw;
}
}
public async Task<ReachabilityDriftResult?> TryGetLatestForHeadAsync(string headScanId, string language, CancellationToken ct = default, string? tenantId = null)
private IEnumerable<string> GetSchemaCandidates()
{
ArgumentException.ThrowIfNullOrWhiteSpace(headScanId);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var trimmedHead = headScanId.Trim();
var trimmedLang = language.Trim();
var primary = SchemaName;
yield return primary;
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.ReachabilityDriftResults
.Include(e => e.DriftedSinks)
.Where(e => e.TenantId == tenantScope.TenantId && e.HeadScanId == trimmedHead && e.Language == trimmedLang)
.OrderByDescending(e => e.DetectedAt)
.FirstOrDefaultAsync(ct)
.ConfigureAwait(false);
return entity is not null ? MapEntityToResult(entity) : null;
}
public async Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null)
{
var tenantScope = ScannerTenantScope.Resolve(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entity = await dbContext.ReachabilityDriftResults
.Include(e => e.DriftedSinks)
.FirstOrDefaultAsync(e => e.TenantId == tenantScope.TenantId && e.Id == driftId, ct)
.ConfigureAwait(false);
return entity is not null ? MapEntityToResult(entity) : null;
}
public async Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default, string? tenantId = null)
{
var tenantScope = ScannerTenantScope.Resolve(tenantId);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
return await dbContext.ReachabilityDriftResults
.AnyAsync(e => e.TenantId == tenantScope.TenantId && e.Id == driftId, ct)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<DriftedSink>> ListSinksAsync(
Guid driftId,
DriftDirection direction,
int offset,
int limit,
CancellationToken ct = default,
string? tenantId = null)
{
if (offset < 0)
if (!string.Equals(primary, ScannerDataSource.DefaultSchema, StringComparison.Ordinal))
{
throw new ArgumentOutOfRangeException(nameof(offset));
yield return ScannerDataSource.DefaultSchema;
}
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit));
}
var tenantScope = ScannerTenantScope.Resolve(tenantId);
var directionValue = ToDbValue(direction);
await using var connection = await _dataSource.OpenConnectionAsync(tenantScope.TenantContext, ct).ConfigureAwait(false);
await using var dbContext = ScannerDbContextFactory.Create(connection, _dataSource.CommandTimeoutSeconds, SchemaName);
var entities = await dbContext.DriftedSinks
.Where(e => e.TenantId == tenantScope.TenantId && e.DriftResultId == driftId && e.Direction == directionValue)
.OrderBy(e => e.SinkNodeId)
.Skip(offset)
.Take(limit)
.ToListAsync(ct)
.ConfigureAwait(false);
return entities.Select(e => MapSinkEntityToModel(e, direction)).ToList();
}
private static bool IsUndefinedTable(PostgresException ex)
=> string.Equals(ex.SqlState, UndefinedTableSqlState, StringComparison.Ordinal);
private static IEnumerable<SinkInsertParams> EnumerateSinkParams(
Guid driftId,
Guid tenantId,

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Npgsql;
using StellaOps.Scanner.Storage.EfCore.CompiledModels;
using StellaOps.Scanner.Storage.EfCore.Context;
@@ -18,7 +19,8 @@ internal static class ScannerDbContextFactory
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<ScannerDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds))
.ReplaceService<IModelCacheKeyFactory, ScannerDbContextModelCacheKeyFactory>();
if (string.Equals(normalizedSchema, ScannerStorageDefaults.DefaultSchemaName, StringComparison.Ordinal))
{
@@ -27,4 +29,17 @@ internal static class ScannerDbContextFactory
return new ScannerDbContext(optionsBuilder.Options, normalizedSchema);
}
private sealed class ScannerDbContextModelCacheKeyFactory : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime)
{
if (context is ScannerDbContext scannerContext)
{
return (context.GetType(), scannerContext.SchemaName, designTime);
}
return (context.GetType(), designTime);
}
}
}