save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}
}

View File

@@ -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",

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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)
};
}

View File

@@ -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();

View File

@@ -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");
}
}