consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user