save progress
This commit is contained in:
@@ -122,7 +122,9 @@ public sealed class AdminOnlyDetector : IGateDetector
|
||||
language.ToLowerInvariant() switch
|
||||
{
|
||||
"c#" or "cs" => "csharp",
|
||||
"dotnet" or ".net" => "csharp",
|
||||
"js" => "javascript",
|
||||
"node" or "nodejs" => "javascript",
|
||||
"ts" => "typescript",
|
||||
"py" => "python",
|
||||
"rb" => "ruby",
|
||||
|
||||
@@ -95,7 +95,9 @@ public sealed class AuthGateDetector : IGateDetector
|
||||
language.ToLowerInvariant() switch
|
||||
{
|
||||
"c#" or "cs" => "csharp",
|
||||
"dotnet" or ".net" => "csharp",
|
||||
"js" => "javascript",
|
||||
"node" or "nodejs" => "javascript",
|
||||
"ts" => "typescript",
|
||||
"py" => "python",
|
||||
"rb" => "ruby",
|
||||
|
||||
@@ -107,7 +107,9 @@ public sealed class FeatureFlagDetector : IGateDetector
|
||||
language.ToLowerInvariant() switch
|
||||
{
|
||||
"c#" or "cs" => "csharp",
|
||||
"dotnet" or ".net" => "csharp",
|
||||
"js" => "javascript",
|
||||
"node" or "nodejs" => "javascript",
|
||||
"ts" => "typescript",
|
||||
"py" => "python",
|
||||
"rb" => "ruby",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Gates.Detectors;
|
||||
|
||||
/// <summary>
|
||||
/// Reads source code directly from the local filesystem.
|
||||
/// </summary>
|
||||
public sealed class FileSystemCodeContentProvider : ICodeContentProvider
|
||||
{
|
||||
public Task<string?> GetContentAsync(string filePath, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
var path = filePath.Trim();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
return File.ReadAllTextAsync(path, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>?> GetLinesAsync(
|
||||
string filePath,
|
||||
int startLine,
|
||||
int endLine,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startLine <= 0 || endLine <= 0 || endLine < startLine)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = filePath.Trim();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lines = new List<string>(Math.Min(256, endLine - startLine + 1));
|
||||
var currentLine = 0;
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var line = await reader.ReadLineAsync().ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentLine++;
|
||||
if (currentLine < startLine)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLine > endLine)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ public sealed class NonDefaultConfigDetector : IGateDetector
|
||||
language.ToLowerInvariant() switch
|
||||
{
|
||||
"c#" or "cs" => "csharp",
|
||||
"dotnet" or ".net" => "csharp",
|
||||
"js" => "javascript",
|
||||
"node" or "nodejs" => "javascript",
|
||||
"ts" => "typescript",
|
||||
"py" => "python",
|
||||
"rb" => "ruby",
|
||||
|
||||
@@ -26,35 +26,22 @@ public sealed class GateMultiplierCalculator
|
||||
if (gates.Count == 0)
|
||||
return 10000; // 100% - no reduction
|
||||
|
||||
// Group gates by type and take highest confidence per type
|
||||
var gatesByType = gates
|
||||
.GroupBy(g => g.Type)
|
||||
.Select(g => new
|
||||
{
|
||||
Type = g.Key,
|
||||
MaxConfidence = g.Max(x => x.Confidence)
|
||||
})
|
||||
var gateTypes = gates
|
||||
.Select(g => g.Type)
|
||||
.Distinct()
|
||||
.OrderBy(t => t)
|
||||
.ToList();
|
||||
|
||||
// Calculate compound multiplier using product reduction
|
||||
// Each gate multiplier is confidence-weighted
|
||||
double multiplier = 1.0;
|
||||
|
||||
foreach (var gate in gatesByType)
|
||||
// Multiply per-type multipliers; gate instances of the same type do not stack.
|
||||
double multiplierBps = 10000.0;
|
||||
foreach (var gateType in gateTypes)
|
||||
{
|
||||
var baseMultiplierBps = _config.GetMultiplierBps(gate.Type);
|
||||
// Scale multiplier by confidence
|
||||
// Low confidence = less reduction, high confidence = more reduction
|
||||
var effectiveMultiplierBps = InterpolateMultiplier(
|
||||
baseMultiplierBps,
|
||||
10000, // No reduction at 0 confidence
|
||||
gate.MaxConfidence);
|
||||
|
||||
multiplier *= effectiveMultiplierBps / 10000.0;
|
||||
var typeMultiplierBps = _config.GetMultiplierBps(gateType);
|
||||
multiplierBps = multiplierBps * typeMultiplierBps / 10000.0;
|
||||
}
|
||||
|
||||
// Apply floor
|
||||
var result = (int)(multiplier * 10000);
|
||||
var result = (int)Math.Round(multiplierBps);
|
||||
result = Math.Clamp(result, 0, _config.MaxMultipliersBps);
|
||||
return Math.Max(result, _config.MinimumMultiplierBps);
|
||||
}
|
||||
|
||||
@@ -65,8 +52,7 @@ public sealed class GateMultiplierCalculator
|
||||
/// <returns>Multiplier in basis points (10000 = 100%).</returns>
|
||||
public int CalculateSingleMultiplierBps(DetectedGate gate)
|
||||
{
|
||||
var baseMultiplierBps = _config.GetMultiplierBps(gate.Type);
|
||||
return InterpolateMultiplier(baseMultiplierBps, 10000, gate.Confidence);
|
||||
return _config.GetMultiplierBps(gate.Type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -93,14 +79,6 @@ public sealed class GateMultiplierCalculator
|
||||
{
|
||||
return baseScore * multiplierBps / 10000.0;
|
||||
}
|
||||
|
||||
private static int InterpolateMultiplier(int minBps, int maxBps, double confidence)
|
||||
{
|
||||
// Linear interpolation: higher confidence = lower multiplier (closer to minBps)
|
||||
var range = maxBps - minBps;
|
||||
var reduction = (int)(range * confidence);
|
||||
return maxBps - reduction;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using GateDetectors = StellaOps.Scanner.Reachability.Gates.Detectors;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
public interface IRichGraphGateAnnotator
|
||||
{
|
||||
Task<RichGraph> AnnotateAsync(RichGraph graph, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches richgraph-v1 edges with detected gates and a combined gate multiplier.
|
||||
/// </summary>
|
||||
public sealed class RichGraphGateAnnotator : IRichGraphGateAnnotator
|
||||
{
|
||||
private readonly IReadOnlyList<GateDetectors.IGateDetector> _detectors;
|
||||
private readonly GateDetectors.ICodeContentProvider _codeProvider;
|
||||
private readonly GateMultiplierCalculator _multiplierCalculator;
|
||||
private readonly ILogger<RichGraphGateAnnotator> _logger;
|
||||
|
||||
public RichGraphGateAnnotator(
|
||||
IEnumerable<GateDetectors.IGateDetector> detectors,
|
||||
GateDetectors.ICodeContentProvider codeProvider,
|
||||
GateMultiplierCalculator multiplierCalculator,
|
||||
ILogger<RichGraphGateAnnotator> logger)
|
||||
{
|
||||
_detectors = (detectors ?? Enumerable.Empty<GateDetectors.IGateDetector>())
|
||||
.Where(d => d is not null)
|
||||
.OrderBy(d => d.GateType)
|
||||
.ToList();
|
||||
_codeProvider = codeProvider ?? throw new ArgumentNullException(nameof(codeProvider));
|
||||
_multiplierCalculator = multiplierCalculator ?? throw new ArgumentNullException(nameof(multiplierCalculator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RichGraph> AnnotateAsync(RichGraph graph, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
if (_detectors.Count == 0)
|
||||
{
|
||||
return graph;
|
||||
}
|
||||
|
||||
var trimmed = graph.Trimmed();
|
||||
|
||||
var incomingByNode = trimmed.Edges
|
||||
.GroupBy(e => e.To, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<RichGraphEdge>)g.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var gatesByNode = new Dictionary<string, IReadOnlyList<DetectedGate>>(StringComparer.Ordinal);
|
||||
foreach (var node in trimmed.Nodes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (sourceFile, lineNumber, endLineNumber) = ExtractSourceLocation(node);
|
||||
var annotations = ExtractAnnotations(node.Attributes);
|
||||
|
||||
var detectorNode = new GateDetectors.RichGraphNode
|
||||
{
|
||||
Symbol = node.SymbolId,
|
||||
SourceFile = sourceFile,
|
||||
LineNumber = lineNumber,
|
||||
EndLineNumber = endLineNumber,
|
||||
Annotations = annotations,
|
||||
Metadata = node.Attributes
|
||||
};
|
||||
|
||||
var incomingEdges = incomingByNode.TryGetValue(node.Id, out var edges)
|
||||
? edges.Select(e => new GateDetectors.RichGraphEdge
|
||||
{
|
||||
FromSymbol = e.From,
|
||||
ToSymbol = e.To,
|
||||
EdgeType = e.Kind,
|
||||
Gates = []
|
||||
})
|
||||
.ToList()
|
||||
: [];
|
||||
|
||||
var detected = new List<DetectedGate>();
|
||||
foreach (var detector in _detectors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await detector.DetectAsync(
|
||||
detectorNode,
|
||||
incomingEdges,
|
||||
_codeProvider,
|
||||
node.Lang,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (results is { Count: > 0 })
|
||||
{
|
||||
detected.AddRange(results);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Gate detector {Detector} failed for node {NodeId}.", detector.GateType, node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
gatesByNode[node.Id] = CanonicalizeGates(detected);
|
||||
}
|
||||
|
||||
var annotatedEdges = new List<RichGraphEdge>(trimmed.Edges.Count);
|
||||
foreach (var edge in trimmed.Edges)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
gatesByNode.TryGetValue(edge.From, out var fromGates);
|
||||
gatesByNode.TryGetValue(edge.To, out var toGates);
|
||||
|
||||
var combined = CombineGates(fromGates, toGates);
|
||||
if (combined.Count == 0 && edge.GateMultiplierBps == 10000 && edge.Gates is not { Count: > 0 })
|
||||
{
|
||||
annotatedEdges.Add(edge);
|
||||
continue;
|
||||
}
|
||||
|
||||
var multiplier = combined.Count == 0
|
||||
? edge.GateMultiplierBps
|
||||
: _multiplierCalculator.CalculateCombinedMultiplierBps(combined);
|
||||
|
||||
annotatedEdges.Add(edge with
|
||||
{
|
||||
Gates = combined,
|
||||
GateMultiplierBps = multiplier
|
||||
});
|
||||
}
|
||||
|
||||
return (trimmed with { Edges = annotatedEdges }).Trimmed();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DetectedGate> CombineGates(
|
||||
IReadOnlyList<DetectedGate>? fromGates,
|
||||
IReadOnlyList<DetectedGate>? toGates)
|
||||
{
|
||||
if (fromGates is not { Count: > 0 } && toGates is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var combined = new List<DetectedGate>((fromGates?.Count ?? 0) + (toGates?.Count ?? 0));
|
||||
if (fromGates is { Count: > 0 })
|
||||
{
|
||||
combined.AddRange(fromGates);
|
||||
}
|
||||
|
||||
if (toGates is { Count: > 0 })
|
||||
{
|
||||
combined.AddRange(toGates);
|
||||
}
|
||||
|
||||
return CanonicalizeGates(combined);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DetectedGate> CanonicalizeGates(IEnumerable<DetectedGate>? gates)
|
||||
{
|
||||
if (gates is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return gates
|
||||
.Where(g => g is not null && !string.IsNullOrWhiteSpace(g.GuardSymbol))
|
||||
.Select(g => g with
|
||||
{
|
||||
Detail = g.Detail.Trim(),
|
||||
GuardSymbol = g.GuardSymbol.Trim(),
|
||||
SourceFile = string.IsNullOrWhiteSpace(g.SourceFile) ? null : g.SourceFile.Trim(),
|
||||
Confidence = Math.Clamp(g.Confidence, 0.0, 1.0),
|
||||
DetectionMethod = g.DetectionMethod.Trim()
|
||||
})
|
||||
.GroupBy(g => (g.Type, g.GuardSymbol))
|
||||
.Select(group => group
|
||||
.OrderByDescending(g => g.Confidence)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
|
||||
.First())
|
||||
.OrderBy(g => g.Type)
|
||||
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ExtractAnnotations(IReadOnlyDictionary<string, string>? attributes)
|
||||
{
|
||||
if (attributes is null || attributes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var annotations = new List<string>();
|
||||
AddDelimited(annotations, TryGet(attributes, "annotations"));
|
||||
AddDelimited(annotations, TryGet(attributes, "annotation"));
|
||||
AddDelimited(annotations, TryGet(attributes, "decorators"));
|
||||
AddDelimited(annotations, TryGet(attributes, "decorator"));
|
||||
|
||||
foreach (var kv in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kv.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kv.Key.StartsWith("annotation:", StringComparison.OrdinalIgnoreCase) ||
|
||||
kv.Key.StartsWith("decorator:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var suffix = kv.Key[(kv.Key.IndexOf(':') + 1)..].Trim();
|
||||
if (!string.IsNullOrWhiteSpace(suffix))
|
||||
{
|
||||
annotations.Add(suffix);
|
||||
}
|
||||
|
||||
AddDelimited(annotations, kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var normalized = annotations
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.Select(a => a.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(a => a, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return normalized.Count == 0 ? null : normalized;
|
||||
}
|
||||
|
||||
private static void AddDelimited(List<string> sink, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
sink.Add(item.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var part in trimmed.Split(new[] { '\r', '\n', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
sink.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? SourceFile, int? LineNumber, int? EndLineNumber) ExtractSourceLocation(RichGraphNode node)
|
||||
{
|
||||
var attributes = node.Attributes;
|
||||
var sourceFile = TryGet(attributes, "source_file")
|
||||
?? TryGet(attributes, "sourceFile")
|
||||
?? TryGet(attributes, "file");
|
||||
|
||||
var line = TryGetInt(attributes, "line_number")
|
||||
?? TryGetInt(attributes, "lineNumber")
|
||||
?? TryGetInt(attributes, "line");
|
||||
|
||||
var endLine = TryGetInt(attributes, "end_line_number")
|
||||
?? TryGetInt(attributes, "endLineNumber")
|
||||
?? TryGetInt(attributes, "end_line")
|
||||
?? TryGetInt(attributes, "endLine");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourceFile))
|
||||
{
|
||||
return (sourceFile.Trim(), line, endLine);
|
||||
}
|
||||
|
||||
if (node.Evidence is { Count: > 0 })
|
||||
{
|
||||
foreach (var evidence in node.Evidence)
|
||||
{
|
||||
if (TryParseFileEvidence(evidence, out var file, out var parsedLine))
|
||||
{
|
||||
return (file, parsedLine, endLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (null, line, endLine);
|
||||
}
|
||||
|
||||
private static bool TryParseFileEvidence(string? evidence, out string filePath, out int? lineNumber)
|
||||
{
|
||||
filePath = string.Empty;
|
||||
lineNumber = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(evidence))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = evidence.Trim();
|
||||
if (!trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remainder = trimmed["file:".Length..];
|
||||
if (string.IsNullOrWhiteSpace(remainder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastColon = remainder.LastIndexOf(':');
|
||||
if (lastColon > 0)
|
||||
{
|
||||
var maybeLine = remainder[(lastColon + 1)..];
|
||||
if (int.TryParse(maybeLine, out var parsed))
|
||||
{
|
||||
filePath = remainder[..lastColon];
|
||||
lineNumber = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
filePath = remainder;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? TryGet(IReadOnlyDictionary<string, string>? dict, string key)
|
||||
=> dict is not null && dict.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
private static int? TryGetInt(IReadOnlyDictionary<string, string>? dict, string key)
|
||||
{
|
||||
if (dict is null || !dict.TryGetValue(key, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
@@ -20,22 +21,30 @@ public sealed class ReachabilityRichGraphPublisherService : IRichGraphPublisherS
|
||||
private readonly ISurfaceEnvironment _environment;
|
||||
private readonly IFileContentAddressableStore _cas;
|
||||
private readonly IRichGraphPublisher _publisher;
|
||||
private readonly IRichGraphGateAnnotator? _gateAnnotator;
|
||||
|
||||
public ReachabilityRichGraphPublisherService(
|
||||
ISurfaceEnvironment environment,
|
||||
IFileContentAddressableStore cas,
|
||||
IRichGraphPublisher publisher)
|
||||
IRichGraphPublisher publisher,
|
||||
IRichGraphGateAnnotator? gateAnnotator = null)
|
||||
{
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_gateAnnotator = gateAnnotator;
|
||||
}
|
||||
|
||||
public Task<RichGraphPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default)
|
||||
public async Task<RichGraphPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var richGraph = RichGraphBuilder.FromUnion(graph, "scanner.reachability", "0.1.0");
|
||||
if (_gateAnnotator is not null)
|
||||
{
|
||||
richGraph = await _gateAnnotator.AnnotateAsync(richGraph, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var workRoot = Path.Combine(_environment.Settings.CacheRoot.FullName, "reachability");
|
||||
Directory.CreateDirectory(workRoot);
|
||||
return _publisher.PublishAsync(richGraph, analysisId, _cas, workRoot, cancellationToken);
|
||||
return await _publisher.PublishAsync(richGraph, analysisId, _cas, workRoot, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
@@ -90,10 +91,34 @@ public sealed record RichGraphEdge(
|
||||
string? SymbolDigest,
|
||||
IReadOnlyList<string>? Evidence,
|
||||
double Confidence,
|
||||
IReadOnlyList<string>? Candidates)
|
||||
IReadOnlyList<string>? Candidates,
|
||||
IReadOnlyList<DetectedGate>? Gates = null,
|
||||
int GateMultiplierBps = 10000)
|
||||
{
|
||||
public RichGraphEdge Trimmed()
|
||||
{
|
||||
var gates = (Gates ?? Array.Empty<DetectedGate>())
|
||||
.Where(g => g is not null)
|
||||
.Select(g => g with
|
||||
{
|
||||
Detail = g.Detail.Trim(),
|
||||
GuardSymbol = g.GuardSymbol.Trim(),
|
||||
SourceFile = string.IsNullOrWhiteSpace(g.SourceFile) ? null : g.SourceFile.Trim(),
|
||||
LineNumber = g.LineNumber,
|
||||
Confidence = ClampConfidence(g.Confidence),
|
||||
DetectionMethod = g.DetectionMethod.Trim()
|
||||
})
|
||||
.GroupBy(g => (g.Type, g.GuardSymbol))
|
||||
.Select(group => group
|
||||
.OrderByDescending(g => g.Confidence)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
|
||||
.First())
|
||||
.OrderBy(g => g.Type)
|
||||
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return this with
|
||||
{
|
||||
From = From.Trim(),
|
||||
@@ -107,7 +132,9 @@ public sealed record RichGraphEdge(
|
||||
Candidates = Candidates is null
|
||||
? Array.Empty<string>()
|
||||
: Candidates.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()).OrderBy(c => c, StringComparer.Ordinal).ToArray(),
|
||||
Confidence = ClampConfidence(Confidence)
|
||||
Confidence = ClampConfidence(Confidence),
|
||||
Gates = gates,
|
||||
GateMultiplierBps = Math.Clamp(GateMultiplierBps, 0, 10000)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
@@ -153,6 +154,30 @@ public sealed class RichGraphWriter
|
||||
if (!string.IsNullOrWhiteSpace(edge.SymbolDigest)) writer.WriteString("symbol_digest", edge.SymbolDigest);
|
||||
writer.WriteNumber("confidence", edge.Confidence);
|
||||
|
||||
if (edge.Gates is { Count: > 0 } || edge.GateMultiplierBps != 10000)
|
||||
{
|
||||
writer.WriteNumber("gate_multiplier_bps", edge.GateMultiplierBps);
|
||||
}
|
||||
|
||||
if (edge.Gates is { Count: > 0 })
|
||||
{
|
||||
writer.WritePropertyName("gates");
|
||||
writer.WriteStartArray();
|
||||
foreach (var gate in edge.Gates)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", GateTypeToLowerCamelCase(gate.Type));
|
||||
writer.WriteString("detail", gate.Detail);
|
||||
writer.WriteString("guard_symbol", gate.GuardSymbol);
|
||||
if (!string.IsNullOrWhiteSpace(gate.SourceFile)) writer.WriteString("source_file", gate.SourceFile);
|
||||
if (gate.LineNumber is not null) writer.WriteNumber("line_number", gate.LineNumber.Value);
|
||||
writer.WriteNumber("confidence", gate.Confidence);
|
||||
writer.WriteString("detection_method", gate.DetectionMethod);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
if (edge.Evidence is { Count: > 0 })
|
||||
{
|
||||
writer.WritePropertyName("evidence");
|
||||
@@ -188,6 +213,16 @@ public sealed class RichGraphWriter
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static string GateTypeToLowerCamelCase(GateType type)
|
||||
=> type switch
|
||||
{
|
||||
GateType.AuthRequired => "authRequired",
|
||||
GateType.FeatureFlag => "featureFlag",
|
||||
GateType.AdminOnly => "adminOnly",
|
||||
GateType.NonDefaultConfig => "nonDefaultConfig",
|
||||
_ => type.ToString()
|
||||
};
|
||||
|
||||
private static void WriteSymbol(Utf8JsonWriter writer, ReachabilitySymbol symbol)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for gate detection and multiplier calculation.
|
||||
/// SPRINT_3405_0001_0001 - Tasks #13, #14, #15
|
||||
/// </summary>
|
||||
public sealed class GateDetectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void GateDetectionResult_Empty_HasNoGates()
|
||||
{
|
||||
// Assert
|
||||
Assert.False(GateDetectionResult.Empty.HasGates);
|
||||
Assert.Empty(GateDetectionResult.Empty.Gates);
|
||||
Assert.Null(GateDetectionResult.Empty.PrimaryGate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateDetectionResult_WithGates_HasPrimaryGate()
|
||||
{
|
||||
// Arrange
|
||||
var gates = new[]
|
||||
{
|
||||
CreateGate(GateType.AuthRequired, 0.7),
|
||||
CreateGate(GateType.FeatureFlag, 0.9),
|
||||
};
|
||||
|
||||
var result = new GateDetectionResult { Gates = gates };
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasGates);
|
||||
Assert.Equal(2, result.Gates.Count);
|
||||
Assert.Equal(GateType.FeatureFlag, result.PrimaryGate?.Type); // Highest confidence
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateMultiplierConfig_Default_HasExpectedValues()
|
||||
{
|
||||
// Arrange
|
||||
var config = GateMultiplierConfig.Default;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3000, config.AuthRequiredMultiplierBps); // 30%
|
||||
Assert.Equal(2000, config.FeatureFlagMultiplierBps); // 20%
|
||||
Assert.Equal(1500, config.AdminOnlyMultiplierBps); // 15%
|
||||
Assert.Equal(5000, config.NonDefaultConfigMultiplierBps); // 50%
|
||||
Assert.Equal(500, config.MinimumMultiplierBps); // 5% floor
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_NoDetectors_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var detector = new CompositeGateDetector([]);
|
||||
var context = CreateContext(["main", "vulnerable_function"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasGates);
|
||||
Assert.Equal(10000, result.CombinedMultiplierBps); // 100%
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_EmptyCallPath_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var detector = new CompositeGateDetector([new MockAuthDetector()]);
|
||||
var context = CreateContext([]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasGates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_SingleGate_AppliesMultiplier()
|
||||
{
|
||||
// Arrange
|
||||
var authDetector = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.95));
|
||||
var detector = new CompositeGateDetector([authDetector]);
|
||||
var context = CreateContext(["main", "auth_check", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasGates);
|
||||
Assert.Single(result.Gates);
|
||||
Assert.Equal(3000, result.CombinedMultiplierBps); // 30% from auth
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_MultipleGateTypes_MultipliesMultipliers()
|
||||
{
|
||||
// Arrange
|
||||
var authDetector = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.9));
|
||||
var featureDetector = new MockFeatureFlagDetector(
|
||||
CreateGate(GateType.FeatureFlag, 0.8));
|
||||
|
||||
var detector = new CompositeGateDetector([authDetector, featureDetector]);
|
||||
var context = CreateContext(["main", "auth_check", "feature_check", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasGates);
|
||||
Assert.Equal(2, result.Gates.Count);
|
||||
// 30% * 20% = 6% (600 bps), but floor is 500 bps
|
||||
Assert.Equal(600, result.CombinedMultiplierBps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_DuplicateGates_Deduplicates()
|
||||
{
|
||||
// Arrange - two detectors finding same gate
|
||||
var authDetector1 = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.9, "checkAuth"));
|
||||
var authDetector2 = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.7, "checkAuth"));
|
||||
|
||||
var detector = new CompositeGateDetector([authDetector1, authDetector2]);
|
||||
var context = CreateContext(["main", "checkAuth", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Gates); // Deduplicated
|
||||
Assert.Equal(0.9, result.Gates[0].Confidence); // Kept higher confidence
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_AllGateTypes_AppliesMinimumFloor()
|
||||
{
|
||||
// Arrange - all gate types = very low multiplier
|
||||
var detectors = new IGateDetector[]
|
||||
{
|
||||
new MockAuthDetector(CreateGate(GateType.AuthRequired, 0.9)),
|
||||
new MockFeatureFlagDetector(CreateGate(GateType.FeatureFlag, 0.9)),
|
||||
new MockAdminDetector(CreateGate(GateType.AdminOnly, 0.9)),
|
||||
new MockConfigDetector(CreateGate(GateType.NonDefaultConfig, 0.9)),
|
||||
};
|
||||
|
||||
var detector = new CompositeGateDetector(detectors);
|
||||
var context = CreateContext(["main", "auth", "feature", "admin", "config", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, result.Gates.Count);
|
||||
// 30% * 20% * 15% * 50% = 0.45%, but floor is 5% (500 bps)
|
||||
Assert.Equal(500, result.CombinedMultiplierBps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_DetectorException_ContinuesWithOthers()
|
||||
{
|
||||
// Arrange
|
||||
var failingDetector = new FailingGateDetector();
|
||||
var authDetector = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.9));
|
||||
|
||||
var detector = new CompositeGateDetector([failingDetector, authDetector]);
|
||||
var context = CreateContext(["main", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert - should still get auth gate despite failing detector
|
||||
Assert.Single(result.Gates);
|
||||
Assert.Equal(GateType.AuthRequired, result.Gates[0].Type);
|
||||
}
|
||||
|
||||
private static DetectedGate CreateGate(GateType type, double confidence, string symbol = "guard_symbol")
|
||||
{
|
||||
return new DetectedGate
|
||||
{
|
||||
Type = type,
|
||||
Detail = $"{type} gate detected",
|
||||
GuardSymbol = symbol,
|
||||
Confidence = confidence,
|
||||
DetectionMethod = "mock",
|
||||
};
|
||||
}
|
||||
|
||||
private static CallPathContext CreateContext(string[] callPath)
|
||||
{
|
||||
return new CallPathContext
|
||||
{
|
||||
CallPath = callPath,
|
||||
Language = "csharp",
|
||||
};
|
||||
}
|
||||
|
||||
// Mock detectors for testing
|
||||
private class MockAuthDetector : IGateDetector
|
||||
{
|
||||
private readonly DetectedGate[] _gates;
|
||||
public GateType GateType => GateType.AuthRequired;
|
||||
|
||||
public MockAuthDetector(params DetectedGate[] gates) => _gates = gates;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<DetectedGate>>(_gates);
|
||||
}
|
||||
|
||||
private class MockFeatureFlagDetector : IGateDetector
|
||||
{
|
||||
private readonly DetectedGate[] _gates;
|
||||
public GateType GateType => GateType.FeatureFlag;
|
||||
|
||||
public MockFeatureFlagDetector(params DetectedGate[] gates) => _gates = gates;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<DetectedGate>>(_gates);
|
||||
}
|
||||
|
||||
private class MockAdminDetector : IGateDetector
|
||||
{
|
||||
private readonly DetectedGate[] _gates;
|
||||
public GateType GateType => GateType.AdminOnly;
|
||||
|
||||
public MockAdminDetector(params DetectedGate[] gates) => _gates = gates;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<DetectedGate>>(_gates);
|
||||
}
|
||||
|
||||
private class MockConfigDetector : IGateDetector
|
||||
{
|
||||
private readonly DetectedGate[] _gates;
|
||||
public GateType GateType => GateType.NonDefaultConfig;
|
||||
|
||||
public MockConfigDetector(params DetectedGate[] gates) => _gates = gates;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<DetectedGate>>(_gates);
|
||||
}
|
||||
|
||||
private class FailingGateDetector : IGateDetector
|
||||
{
|
||||
public GateType GateType => GateType.AuthRequired;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> throw new InvalidOperationException("Simulated detector failure");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user