feat: Add VEX Status Chip component and integration tests for reachability drift detection

- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips.
- Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling.
- Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation.
- Updated project references to include the new Reachability Drift library.
This commit is contained in:
StellaOps Bot
2025-12-20 01:26:42 +02:00
parent edc91ea96f
commit 5fc469ad98
159 changed files with 41116 additions and 2305 deletions

View File

@@ -0,0 +1,99 @@
using System;
using System.Text.RegularExpressions;
using CycloneDX.Models;
namespace StellaOps.Scanner.Emit.Composition;
/// <summary>
/// Extension methods for CycloneDX 1.7 support.
/// Workaround for CycloneDX.Core not yet exposing SpecificationVersion.v1_7.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_5000_0001_0001 - Advisory Alignment (CycloneDX 1.7 Upgrade)
///
/// Once CycloneDX.Core adds v1_7 support, this extension can be removed
/// and the code can use SpecificationVersion.v1_7 directly.
/// </remarks>
public static class CycloneDx17Extensions
{
/// <summary>
/// CycloneDX 1.7 media types.
/// </summary>
public static class MediaTypes
{
public const string InventoryJson = "application/vnd.cyclonedx+json; version=1.7";
public const string UsageJson = "application/vnd.cyclonedx+json; version=1.7; view=usage";
public const string InventoryProtobuf = "application/vnd.cyclonedx+protobuf; version=1.7";
public const string UsageProtobuf = "application/vnd.cyclonedx+protobuf; version=1.7; view=usage";
}
// Regex patterns for version replacement in serialized output
private static readonly Regex JsonSpecVersionPattern = new(
@"""specVersion""\s*:\s*""1\.6""",
RegexOptions.Compiled);
private static readonly Regex XmlSpecVersionPattern = new(
@"specVersion=""1\.6""",
RegexOptions.Compiled);
/// <summary>
/// Upgrades a CycloneDX 1.6 JSON string to 1.7 format.
/// </summary>
/// <param name="json1_6">The JSON serialized with v1_6.</param>
/// <returns>The JSON with specVersion updated to 1.7.</returns>
public static string UpgradeJsonTo17(string json1_6)
{
if (string.IsNullOrEmpty(json1_6))
{
return json1_6;
}
return JsonSpecVersionPattern.Replace(json1_6, @"""specVersion"": ""1.7""");
}
/// <summary>
/// Upgrades a CycloneDX 1.6 XML string to 1.7 format.
/// </summary>
/// <param name="xml1_6">The XML serialized with v1_6.</param>
/// <returns>The XML with specVersion updated to 1.7.</returns>
public static string UpgradeXmlTo17(string xml1_6)
{
if (string.IsNullOrEmpty(xml1_6))
{
return xml1_6;
}
return XmlSpecVersionPattern.Replace(xml1_6, @"specVersion=""1.7""");
}
/// <summary>
/// Upgrades a media type string from 1.6 to 1.7.
/// </summary>
public static string UpgradeMediaTypeTo17(string mediaType)
{
if (string.IsNullOrEmpty(mediaType))
{
return mediaType;
}
return mediaType.Replace("version=1.6", "version=1.7", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Checks if CycloneDX.Core supports v1_7 natively.
/// Returns true when the library is updated and this workaround can be removed.
/// </summary>
public static bool IsNativeV17Supported()
{
// Check if v1_7 enum value exists via reflection
var values = Enum.GetNames(typeof(SpecificationVersion));
foreach (var value in values)
{
if (value.Equals("v1_7", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,635 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.EntryTrace.FileSystem;
namespace StellaOps.Scanner.EntryTrace.Baseline;
/// <summary>
/// Context for baseline analysis.
/// </summary>
public sealed record BaselineAnalysisContext
{
/// <summary>Scan identifier.</summary>
public required string ScanId { get; init; }
/// <summary>Root path for scanning.</summary>
public required string RootPath { get; init; }
/// <summary>Configuration to use.</summary>
public required EntryTraceBaselineConfig Config { get; init; }
/// <summary>File system abstraction.</summary>
public IRootFileSystem? FileSystem { get; init; }
/// <summary>Known vulnerabilities for reachability analysis.</summary>
public IReadOnlyList<string>? KnownVulnerabilities { get; init; }
}
/// <summary>
/// Interface for baseline entry point analysis.
/// </summary>
public interface IBaselineAnalyzer
{
/// <summary>
/// Performs baseline entry point analysis.
/// </summary>
Task<BaselineReport> AnalyzeAsync(
BaselineAnalysisContext context,
CancellationToken cancellationToken = default);
/// <summary>
/// Streams detected entry points for large codebases.
/// </summary>
IAsyncEnumerable<DetectedEntryPoint> StreamEntryPointsAsync(
BaselineAnalysisContext context,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Pattern-based baseline analyzer for entry point detection.
/// </summary>
/// <remarks>
/// Implements SCANNER-ENTRYTRACE-18-508: EntryTrace baseline analysis.
/// </remarks>
public sealed class BaselineAnalyzer : IBaselineAnalyzer
{
private readonly ILogger<BaselineAnalyzer> _logger;
private readonly Dictionary<string, Regex> _compiledPatterns = new();
public BaselineAnalyzer(ILogger<BaselineAnalyzer> logger)
{
_logger = logger;
}
public async Task<BaselineReport> AnalyzeAsync(
BaselineAnalysisContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var entryPoints = new List<DetectedEntryPoint>();
var frameworksDetected = new HashSet<string>();
var filesAnalyzed = 0;
var filesSkipped = 0;
_logger.LogInformation("Starting baseline analysis for scan {ScanId}", context.ScanId);
await foreach (var entryPoint in StreamEntryPointsAsync(context, cancellationToken))
{
entryPoints.Add(entryPoint);
if (entryPoint.Framework is not null)
{
frameworksDetected.Add(entryPoint.Framework);
}
}
// Count files (simplified - would need proper tracking in production)
filesAnalyzed = await CountFilesAsync(context, cancellationToken);
stopwatch.Stop();
var statistics = ComputeStatistics(entryPoints, filesAnalyzed, filesSkipped);
var digest = BaselineReport.ComputeDigest(entryPoints);
var report = new BaselineReport
{
ReportId = Guid.NewGuid(),
ScanId = context.ScanId,
GeneratedAt = DateTimeOffset.UtcNow,
ConfigUsed = context.Config.ConfigId,
EntryPoints = entryPoints.ToImmutableArray(),
Statistics = statistics,
FrameworksDetected = frameworksDetected.OrderBy(f => f).ToImmutableArray(),
AnalysisDurationMs = stopwatch.ElapsedMilliseconds,
Digest = digest
};
_logger.LogInformation(
"Baseline analysis complete: {EntryPointCount} entry points in {Duration}ms",
entryPoints.Count, stopwatch.ElapsedMilliseconds);
return report;
}
public async IAsyncEnumerable<DetectedEntryPoint> StreamEntryPointsAsync(
BaselineAnalysisContext context,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var config = context.Config;
var fileExtensions = GetFileExtensions(config.Language);
var excludePatterns = BuildExcludePatterns(config.Exclusions);
await foreach (var filePath in EnumerateFilesAsync(context.RootPath, fileExtensions, cancellationToken))
{
if (ShouldExclude(filePath, excludePatterns, config.Exclusions))
{
continue;
}
string content;
try
{
content = await File.ReadAllTextAsync(filePath, cancellationToken);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read file {FilePath}", filePath);
continue;
}
var relativePath = Path.GetRelativePath(context.RootPath, filePath);
var lines = content.Split('\n');
var detectedFramework = DetectFramework(content, config.FrameworkConfigs);
foreach (var pattern in config.EntryPointPatterns)
{
// Skip patterns not for this framework
if (pattern.Framework is not null && detectedFramework is not null &&
!pattern.Framework.Equals(detectedFramework, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var matches = FindMatches(content, lines, pattern, relativePath);
foreach (var match in matches)
{
if (match.Confidence >= config.Heuristics.ConfidenceThreshold)
{
var entryPoint = CreateEntryPoint(match, pattern, detectedFramework);
yield return entryPoint;
}
}
}
}
}
private IEnumerable<PatternMatch> FindMatches(
string content,
string[] lines,
EntryPointPattern pattern,
string filePath)
{
var regex = GetCompiledPattern(pattern);
if (regex is null)
yield break;
var matches = regex.Matches(content);
foreach (Match match in matches)
{
var (line, column) = GetLineAndColumn(content, match.Index);
var functionName = ExtractFunctionName(lines, line);
var confidence = CalculateConfidence(pattern, match, lines, line);
yield return new PatternMatch
{
FilePath = filePath,
Line = line,
Column = column,
MatchedText = match.Value,
FunctionName = functionName,
Pattern = pattern,
Confidence = confidence,
Groups = match.Groups.Cast<Group>()
.Where(g => g.Success && !string.IsNullOrEmpty(g.Name) && !int.TryParse(g.Name, out _))
.ToImmutableDictionary(g => g.Name, g => g.Value)
};
}
}
private Regex? GetCompiledPattern(EntryPointPattern pattern)
{
if (_compiledPatterns.TryGetValue(pattern.PatternId, out var cached))
return cached;
try
{
var regex = new Regex(
pattern.Pattern,
RegexOptions.Compiled | RegexOptions.Multiline,
TimeSpan.FromSeconds(5));
_compiledPatterns[pattern.PatternId] = regex;
return regex;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to compile pattern {PatternId}: {Pattern}",
pattern.PatternId, pattern.Pattern);
return null;
}
}
private string? DetectFramework(string content, ImmutableArray<FrameworkConfig> frameworks)
{
foreach (var framework in frameworks)
{
foreach (var detection in framework.DetectionPatterns)
{
if (content.Contains(detection, StringComparison.OrdinalIgnoreCase))
{
return framework.FrameworkId;
}
}
}
return null;
}
private static (int line, int column) GetLineAndColumn(string content, int index)
{
var line = 1;
var lastNewline = -1;
for (var i = 0; i < index && i < content.Length; i++)
{
if (content[i] == '\n')
{
line++;
lastNewline = i;
}
}
var column = index - lastNewline;
return (line, column);
}
private static string? ExtractFunctionName(string[] lines, int lineNumber)
{
if (lineNumber < 1 || lineNumber > lines.Length)
return null;
var line = lines[lineNumber - 1];
// Try common function/method patterns
var patterns = new[]
{
@"(?:def|function|func)\s+(\w+)", // Python, JS, Go
@"(?:public|private|protected)?\s*(?:static)?\s*\w+\s+(\w+)\s*\(", // Java/C#
@"(\w+)\s*[=:]\s*(?:async\s+)?(?:function|\()", // JS arrow/named
};
foreach (var pattern in patterns)
{
var match = Regex.Match(line, pattern);
if (match.Success && match.Groups.Count > 1)
{
return match.Groups[1].Value;
}
}
return null;
}
private double CalculateConfidence(
EntryPointPattern pattern,
Match match,
string[] lines,
int lineNumber)
{
var baseConfidence = pattern.Confidence;
// Boost for annotation patterns (highest reliability)
if (pattern.Type == PatternType.Annotation || pattern.Type == PatternType.Decorator)
{
baseConfidence = Math.Min(1.0, baseConfidence * 1.1);
}
// Check surrounding context for additional confidence
if (lineNumber > 0 && lineNumber <= lines.Length)
{
var line = lines[lineNumber - 1];
// Boost if line contains routing keywords
if (Regex.IsMatch(line, @"\b(route|path|endpoint|api|handler)\b", RegexOptions.IgnoreCase))
{
baseConfidence = Math.Min(1.0, baseConfidence + 0.05);
}
// Reduce for test files (if not already excluded)
if (Regex.IsMatch(line, @"\b(test|spec|mock)\b", RegexOptions.IgnoreCase))
{
baseConfidence *= 0.5;
}
}
return Math.Round(baseConfidence, 3);
}
private DetectedEntryPoint CreateEntryPoint(
PatternMatch match,
EntryPointPattern pattern,
string? framework)
{
var entryId = DetectedEntryPoint.GenerateEntryId(
match.FilePath,
match.FunctionName ?? "anonymous",
match.Line,
pattern.EntryType);
var httpMetadata = ExtractHttpMetadata(match, pattern);
var parameters = ExtractParameters(match, pattern);
return new DetectedEntryPoint
{
EntryId = entryId,
Type = pattern.EntryType,
Name = match.FunctionName ?? "anonymous",
Location = new CodeLocation
{
FilePath = match.FilePath,
LineStart = match.Line,
LineEnd = match.Line,
ColumnStart = match.Column,
ColumnEnd = match.Column + match.MatchedText.Length,
FunctionName = match.FunctionName
},
Confidence = match.Confidence,
Framework = framework ?? pattern.Framework,
HttpMetadata = httpMetadata,
Parameters = parameters,
DetectionMethod = pattern.PatternId
};
}
private HttpMetadata? ExtractHttpMetadata(PatternMatch match, EntryPointPattern pattern)
{
if (pattern.EntryType != EntryPointType.HttpEndpoint)
return null;
// Try to extract HTTP method and path from match groups
var method = HttpMethod.GET;
var path = "/";
if (match.Groups.TryGetValue("method", out var methodStr))
{
method = ParseHttpMethod(methodStr);
}
else if (pattern.PatternId.Contains("get", StringComparison.OrdinalIgnoreCase))
{
method = HttpMethod.GET;
}
else if (pattern.PatternId.Contains("post", StringComparison.OrdinalIgnoreCase))
{
method = HttpMethod.POST;
}
else if (pattern.PatternId.Contains("put", StringComparison.OrdinalIgnoreCase))
{
method = HttpMethod.PUT;
}
else if (pattern.PatternId.Contains("delete", StringComparison.OrdinalIgnoreCase))
{
method = HttpMethod.DELETE;
}
if (match.Groups.TryGetValue("path", out var pathStr))
{
path = pathStr;
}
else
{
// Try to extract path from matched text
var pathMatch = Regex.Match(match.MatchedText, @"['""]([^'""]+)['""]");
if (pathMatch.Success)
{
path = pathMatch.Groups[1].Value;
}
}
// Extract path parameters
var pathParams = Regex.Matches(path, @":(\w+)|{(\w+)}")
.Cast<Match>()
.Select(m => m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value)
.ToImmutableArray();
return new HttpMetadata
{
Method = method,
Path = path,
PathParameters = pathParams
};
}
private static HttpMethod ParseHttpMethod(string method)
{
return method.ToUpperInvariant() switch
{
"GET" => HttpMethod.GET,
"POST" => HttpMethod.POST,
"PUT" => HttpMethod.PUT,
"PATCH" => HttpMethod.PATCH,
"DELETE" => HttpMethod.DELETE,
"HEAD" => HttpMethod.HEAD,
"OPTIONS" => HttpMethod.OPTIONS,
_ => HttpMethod.GET
};
}
private static ImmutableArray<ParameterInfo> ExtractParameters(PatternMatch match, EntryPointPattern pattern)
{
var parameters = new List<ParameterInfo>();
// Extract path parameters from HTTP metadata
if (pattern.EntryType == EntryPointType.HttpEndpoint)
{
var pathMatch = Regex.Match(match.MatchedText, @"['""]([^'""]+)['""]");
if (pathMatch.Success)
{
var path = pathMatch.Groups[1].Value;
var pathParams = Regex.Matches(path, @":(\w+)|{(\w+)}");
foreach (Match pm in pathParams)
{
var name = pm.Groups[1].Success ? pm.Groups[1].Value : pm.Groups[2].Value;
parameters.Add(new ParameterInfo
{
Name = name,
Source = ParameterSource.Path,
Required = true,
Tainted = true
});
}
}
}
return parameters.ToImmutableArray();
}
private static IEnumerable<string> GetFileExtensions(EntryTraceLanguage language)
{
return language switch
{
EntryTraceLanguage.Java => new[] { ".java" },
EntryTraceLanguage.Python => new[] { ".py" },
EntryTraceLanguage.JavaScript => new[] { ".js", ".mjs", ".cjs" },
EntryTraceLanguage.TypeScript => new[] { ".ts", ".tsx", ".mts", ".cts" },
EntryTraceLanguage.Go => new[] { ".go" },
EntryTraceLanguage.Ruby => new[] { ".rb" },
EntryTraceLanguage.Php => new[] { ".php" },
EntryTraceLanguage.CSharp => new[] { ".cs" },
EntryTraceLanguage.Rust => new[] { ".rs" },
_ => Array.Empty<string>()
};
}
private static IReadOnlyList<Regex> BuildExcludePatterns(ExclusionConfig exclusions)
{
var patterns = new List<Regex>();
foreach (var glob in exclusions.ExcludePaths)
{
try
{
// Convert glob to regex
var pattern = "^" + Regex.Escape(glob)
.Replace(@"\*\*", ".*")
.Replace(@"\*", "[^/\\\\]*")
.Replace(@"\?", ".") + "$";
patterns.Add(new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase));
}
catch
{
// Skip invalid patterns
}
}
return patterns;
}
private static bool ShouldExclude(string filePath, IReadOnlyList<Regex> excludePatterns, ExclusionConfig config)
{
var fileName = Path.GetFileName(filePath);
var normalizedPath = filePath.Replace('\\', '/');
// Check test file exclusion
if (config.ExcludeTestFiles)
{
if (Regex.IsMatch(fileName, @"[._-]?(test|spec|tests|specs)[._-]?", RegexOptions.IgnoreCase) ||
normalizedPath.Contains("/test/", StringComparison.OrdinalIgnoreCase) ||
normalizedPath.Contains("/tests/", StringComparison.OrdinalIgnoreCase) ||
normalizedPath.Contains("/__tests__/", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
// Check generated file exclusion
if (config.ExcludeGenerated)
{
if (normalizedPath.Contains("/generated/", StringComparison.OrdinalIgnoreCase) ||
normalizedPath.Contains("/gen/", StringComparison.OrdinalIgnoreCase) ||
fileName.EndsWith(".generated.cs", StringComparison.OrdinalIgnoreCase) ||
fileName.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
// Check glob patterns
foreach (var pattern in excludePatterns)
{
if (pattern.IsMatch(normalizedPath))
{
return true;
}
}
return false;
}
private static async IAsyncEnumerable<string> EnumerateFilesAsync(
string rootPath,
IEnumerable<string> extensions,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var extensionSet = extensions.ToHashSet(StringComparer.OrdinalIgnoreCase);
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(rootPath, "*", SearchOption.AllDirectories);
}
catch (Exception)
{
yield break;
}
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
var ext = Path.GetExtension(file);
if (extensionSet.Contains(ext))
{
yield return file;
}
}
await Task.CompletedTask;
}
private static async Task<int> CountFilesAsync(BaselineAnalysisContext context, CancellationToken cancellationToken)
{
var extensions = GetFileExtensions(context.Config.Language);
var count = 0;
await foreach (var _ in EnumerateFilesAsync(context.RootPath, extensions, cancellationToken))
{
count++;
}
return count;
}
private static BaselineStatistics ComputeStatistics(
List<DetectedEntryPoint> entryPoints,
int filesAnalyzed,
int filesSkipped)
{
var byType = entryPoints
.GroupBy(e => e.Type)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var byFramework = entryPoints
.Where(e => e.Framework is not null)
.GroupBy(e => e.Framework!)
.ToImmutableDictionary(g => g.Key, g => g.Count());
var highConfidence = entryPoints.Count(e => e.Confidence >= 0.8);
var mediumConfidence = entryPoints.Count(e => e.Confidence >= 0.5 && e.Confidence < 0.8);
var lowConfidence = entryPoints.Count(e => e.Confidence < 0.5);
var reachableVulns = entryPoints
.SelectMany(e => e.ReachableVulnerabilities)
.Distinct()
.Count();
return new BaselineStatistics
{
TotalEntryPoints = entryPoints.Count,
ByType = byType,
ByFramework = byFramework,
HighConfidenceCount = highConfidence,
MediumConfidenceCount = mediumConfidence,
LowConfidenceCount = lowConfidence,
FilesAnalyzed = filesAnalyzed,
FilesSkipped = filesSkipped,
ReachableVulnerabilities = reachableVulns
};
}
private sealed record PatternMatch
{
public required string FilePath { get; init; }
public required int Line { get; init; }
public required int Column { get; init; }
public required string MatchedText { get; init; }
public string? FunctionName { get; init; }
public required EntryPointPattern Pattern { get; init; }
public required double Confidence { get; init; }
public ImmutableDictionary<string, string> Groups { get; init; } =
ImmutableDictionary<string, string>.Empty;
}
}

View File

@@ -0,0 +1,540 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.EntryTrace.Baseline;
/// <summary>
/// Configuration for entry trace baseline analysis.
/// </summary>
/// <remarks>
/// Implements SCANNER-ENTRYTRACE-18-508: EntryTrace baseline schema per
/// docs/schemas/scanner-entrytrace-baseline.schema.json
/// </remarks>
public sealed record EntryTraceBaselineConfig
{
/// <summary>Unique configuration identifier.</summary>
public required string ConfigId { get; init; }
/// <summary>Target language for this configuration.</summary>
public required EntryTraceLanguage Language { get; init; }
/// <summary>Configuration version.</summary>
public string? Version { get; init; }
/// <summary>Entry point detection patterns.</summary>
public ImmutableArray<EntryPointPattern> EntryPointPatterns { get; init; } = ImmutableArray<EntryPointPattern>.Empty;
/// <summary>Framework-specific configurations.</summary>
public ImmutableArray<FrameworkConfig> FrameworkConfigs { get; init; } = ImmutableArray<FrameworkConfig>.Empty;
/// <summary>Heuristics configuration.</summary>
public HeuristicsConfig Heuristics { get; init; } = HeuristicsConfig.Default;
/// <summary>Exclusion rules.</summary>
public ExclusionConfig Exclusions { get; init; } = ExclusionConfig.Default;
}
/// <summary>
/// Supported languages for entry trace analysis.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EntryTraceLanguage
{
Java,
Python,
JavaScript,
TypeScript,
Go,
Ruby,
Php,
CSharp,
Rust
}
/// <summary>
/// Types of entry points that can be detected.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EntryPointType
{
/// <summary>HTTP/REST endpoint.</summary>
HttpEndpoint,
/// <summary>gRPC method.</summary>
GrpcMethod,
/// <summary>CLI command handler.</summary>
CliCommand,
/// <summary>Event handler (Kafka, RabbitMQ, etc.).</summary>
EventHandler,
/// <summary>Scheduled job (cron, timer).</summary>
ScheduledJob,
/// <summary>Message queue consumer.</summary>
MessageConsumer,
/// <summary>Test method (for test coverage).</summary>
TestMethod
}
/// <summary>
/// Pattern types for detecting entry points.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PatternType
{
/// <summary>Annotation/attribute match (e.g., @GetMapping).</summary>
Annotation,
/// <summary>Decorator match (e.g., @app.route).</summary>
Decorator,
/// <summary>Function name pattern.</summary>
FunctionName,
/// <summary>Class name pattern.</summary>
ClassName,
/// <summary>File path pattern.</summary>
FilePattern,
/// <summary>Import statement pattern.</summary>
ImportPattern,
/// <summary>AST pattern for complex matching.</summary>
AstPattern
}
/// <summary>
/// Pattern for detecting entry points.
/// </summary>
public sealed record EntryPointPattern
{
/// <summary>Unique pattern identifier.</summary>
public required string PatternId { get; init; }
/// <summary>Type of pattern matching to use.</summary>
public required PatternType Type { get; init; }
/// <summary>Regex or AST pattern string.</summary>
public required string Pattern { get; init; }
/// <summary>Confidence level for matches (0.0-1.0).</summary>
public double Confidence { get; init; } = 0.7;
/// <summary>Type of entry point this pattern detects.</summary>
public EntryPointType EntryType { get; init; } = EntryPointType.HttpEndpoint;
/// <summary>Associated framework name.</summary>
public string? Framework { get; init; }
/// <summary>Rules for extracting metadata from matches.</summary>
public MetadataExtractionRules? MetadataExtraction { get; init; }
}
/// <summary>
/// Rules for extracting metadata from entry point matches.
/// </summary>
public sealed record MetadataExtractionRules
{
/// <summary>Expression to extract HTTP method.</summary>
public string? HttpMethod { get; init; }
/// <summary>Expression to extract route path.</summary>
public string? RoutePath { get; init; }
/// <summary>Expression to extract parameters.</summary>
public string? Parameters { get; init; }
/// <summary>Expression to detect auth requirements.</summary>
public string? AuthRequired { get; init; }
}
/// <summary>
/// Framework-specific configuration.
/// </summary>
public sealed record FrameworkConfig
{
/// <summary>Unique framework identifier.</summary>
public required string FrameworkId { get; init; }
/// <summary>Display name.</summary>
public required string Name { get; init; }
/// <summary>Supported version range (semver).</summary>
public string? VersionRange { get; init; }
/// <summary>Patterns to detect framework usage.</summary>
public ImmutableArray<string> DetectionPatterns { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Entry point pattern IDs applicable to this framework.</summary>
public ImmutableArray<string> EntryPatterns { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Glob patterns for router/route files.</summary>
public ImmutableArray<string> RouterFilePatterns { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Patterns to identify controller classes.</summary>
public ImmutableArray<string> ControllerPatterns { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// Heuristics configuration for entry point detection.
/// </summary>
public sealed record HeuristicsConfig
{
/// <summary>Enable static code analysis.</summary>
public bool EnableStaticAnalysis { get; init; } = true;
/// <summary>Use runtime hints if available.</summary>
public bool EnableDynamicHints { get; init; } = false;
/// <summary>Minimum confidence to report entry point.</summary>
public double ConfidenceThreshold { get; init; } = 0.7;
/// <summary>Maximum call graph depth to analyze.</summary>
public int MaxDepth { get; init; } = 10;
/// <summary>Analysis timeout per file in seconds.</summary>
public int TimeoutSeconds { get; init; } = 300;
/// <summary>Scoring weights for confidence calculation.</summary>
public ScoringWeights Weights { get; init; } = ScoringWeights.Default;
public static HeuristicsConfig Default => new();
}
/// <summary>
/// Weights for confidence scoring.
/// </summary>
public sealed record ScoringWeights
{
/// <summary>Weight for annotation/decorator matches.</summary>
public double AnnotationMatch { get; init; } = 0.9;
/// <summary>Weight for naming convention matches.</summary>
public double NamingConvention { get; init; } = 0.6;
/// <summary>Weight for file location patterns.</summary>
public double FileLocation { get; init; } = 0.5;
/// <summary>Weight for import analysis.</summary>
public double ImportAnalysis { get; init; } = 0.7;
/// <summary>Weight for call graph centrality.</summary>
public double CallGraphCentrality { get; init; } = 0.4;
public static ScoringWeights Default => new();
}
/// <summary>
/// Exclusion rules for analysis.
/// </summary>
public sealed record ExclusionConfig
{
/// <summary>Glob patterns for paths to exclude.</summary>
public ImmutableArray<string> ExcludePaths { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Package names to exclude.</summary>
public ImmutableArray<string> ExcludePackages { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Exclude test files from analysis.</summary>
public bool ExcludeTestFiles { get; init; } = true;
/// <summary>Exclude generated files from analysis.</summary>
public bool ExcludeGenerated { get; init; } = true;
public static ExclusionConfig Default => new();
}
/// <summary>
/// Source code location.
/// </summary>
public sealed record CodeLocation
{
/// <summary>File path relative to scan root.</summary>
public required string FilePath { get; init; }
/// <summary>Starting line number (1-indexed).</summary>
public int LineStart { get; init; }
/// <summary>Ending line number.</summary>
public int LineEnd { get; init; }
/// <summary>Starting column.</summary>
public int ColumnStart { get; init; }
/// <summary>Ending column.</summary>
public int ColumnEnd { get; init; }
/// <summary>Containing function name.</summary>
public string? FunctionName { get; init; }
/// <summary>Containing class name.</summary>
public string? ClassName { get; init; }
/// <summary>Package/namespace name.</summary>
public string? PackageName { get; init; }
}
/// <summary>
/// HTTP endpoint metadata.
/// </summary>
public sealed record HttpMetadata
{
/// <summary>HTTP method.</summary>
public HttpMethod Method { get; init; } = HttpMethod.GET;
/// <summary>Route path.</summary>
public required string Path { get; init; }
/// <summary>Path parameters.</summary>
public ImmutableArray<string> PathParameters { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Query parameters.</summary>
public ImmutableArray<string> QueryParameters { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Consumed content types.</summary>
public ImmutableArray<string> Consumes { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Produced content types.</summary>
public ImmutableArray<string> Produces { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Whether authentication is required.</summary>
public bool AuthRequired { get; init; }
/// <summary>Required auth scopes.</summary>
public ImmutableArray<string> AuthScopes { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// HTTP methods.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum HttpMethod
{
GET,
POST,
PUT,
PATCH,
DELETE,
HEAD,
OPTIONS
}
/// <summary>
/// Parameter source types.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ParameterSource
{
Path,
Query,
Header,
Body,
Form,
Cookie
}
/// <summary>
/// Entry point parameter information.
/// </summary>
public sealed record ParameterInfo
{
/// <summary>Parameter name.</summary>
public required string Name { get; init; }
/// <summary>Parameter type.</summary>
public string? Type { get; init; }
/// <summary>Source of the parameter value.</summary>
public ParameterSource Source { get; init; } = ParameterSource.Query;
/// <summary>Whether the parameter is required.</summary>
public bool Required { get; init; }
/// <summary>Whether this is a potential taint source.</summary>
public bool Tainted { get; init; }
}
/// <summary>
/// Call type in call graph.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum CallType
{
Direct,
Virtual,
Interface,
Reflection,
Lambda
}
/// <summary>
/// Individual call site in a call path.
/// </summary>
public sealed record CallSite
{
/// <summary>Caller function/method.</summary>
public required string Caller { get; init; }
/// <summary>Callee function/method.</summary>
public required string Callee { get; init; }
/// <summary>Source location.</summary>
public CodeLocation? Location { get; init; }
/// <summary>Type of call.</summary>
public CallType CallType { get; init; } = CallType.Direct;
}
/// <summary>
/// Call path from entry point to vulnerability.
/// </summary>
public sealed record CallPath
{
/// <summary>Target CVE or vulnerability identifier.</summary>
public required string TargetVulnerability { get; init; }
/// <summary>Number of calls in the path.</summary>
public int PathLength { get; init; }
/// <summary>Call sites along the path.</summary>
public ImmutableArray<CallSite> Calls { get; init; } = ImmutableArray<CallSite>.Empty;
/// <summary>Confidence in the path (0.0-1.0).</summary>
public double Confidence { get; init; }
}
/// <summary>
/// Detected entry point.
/// </summary>
public sealed record DetectedEntryPoint
{
/// <summary>Unique entry point identifier (deterministic).</summary>
public required string EntryId { get; init; }
/// <summary>Type of entry point.</summary>
public required EntryPointType Type { get; init; }
/// <summary>Entry point name (function/method name).</summary>
public required string Name { get; init; }
/// <summary>Source code location.</summary>
public required CodeLocation Location { get; init; }
/// <summary>Detection confidence (0.0-1.0).</summary>
public double Confidence { get; init; }
/// <summary>Detected framework.</summary>
public string? Framework { get; init; }
/// <summary>HTTP-specific metadata (if applicable).</summary>
public HttpMetadata? HttpMetadata { get; init; }
/// <summary>Parameters of the entry point.</summary>
public ImmutableArray<ParameterInfo> Parameters { get; init; } = ImmutableArray<ParameterInfo>.Empty;
/// <summary>CVE IDs reachable from this entry point.</summary>
public ImmutableArray<string> ReachableVulnerabilities { get; init; } = ImmutableArray<string>.Empty;
/// <summary>Call paths to vulnerabilities.</summary>
public ImmutableArray<CallPath> CallPaths { get; init; } = ImmutableArray<CallPath>.Empty;
/// <summary>Pattern ID that detected this entry point.</summary>
public string? DetectionMethod { get; init; }
/// <summary>
/// Generates a deterministic entry ID.
/// </summary>
public static string GenerateEntryId(string filePath, string name, int line, EntryPointType type)
{
var input = $"{filePath}|{name}|{line}|{type}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"ep:{Convert.ToHexString(hash).ToLowerInvariant()[..16]}";
}
}
/// <summary>
/// Baseline analysis statistics.
/// </summary>
public sealed record BaselineStatistics
{
/// <summary>Total number of entry points detected.</summary>
public int TotalEntryPoints { get; init; }
/// <summary>Entry points by type.</summary>
public ImmutableDictionary<EntryPointType, int> ByType { get; init; } =
ImmutableDictionary<EntryPointType, int>.Empty;
/// <summary>Entry points by framework.</summary>
public ImmutableDictionary<string, int> ByFramework { get; init; } =
ImmutableDictionary<string, int>.Empty;
/// <summary>Entry points by confidence level.</summary>
public int HighConfidenceCount { get; init; }
public int MediumConfidenceCount { get; init; }
public int LowConfidenceCount { get; init; }
/// <summary>Number of files analyzed.</summary>
public int FilesAnalyzed { get; init; }
/// <summary>Number of files skipped.</summary>
public int FilesSkipped { get; init; }
/// <summary>Number of reachable vulnerabilities.</summary>
public int ReachableVulnerabilities { get; init; }
}
/// <summary>
/// Entry trace baseline analysis report.
/// </summary>
public sealed record BaselineReport
{
/// <summary>Unique report identifier.</summary>
public required Guid ReportId { get; init; }
/// <summary>Scan identifier.</summary>
public required string ScanId { get; init; }
/// <summary>Report generation timestamp (UTC ISO-8601).</summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>Configuration ID used for analysis.</summary>
public string? ConfigUsed { get; init; }
/// <summary>Detected entry points.</summary>
public ImmutableArray<DetectedEntryPoint> EntryPoints { get; init; } =
ImmutableArray<DetectedEntryPoint>.Empty;
/// <summary>Analysis statistics.</summary>
public BaselineStatistics Statistics { get; init; } = new();
/// <summary>Detected frameworks.</summary>
public ImmutableArray<string> FrameworksDetected { get; init; } =
ImmutableArray<string>.Empty;
/// <summary>Analysis duration in milliseconds.</summary>
public long AnalysisDurationMs { get; init; }
/// <summary>Report digest (sha256:...).</summary>
public required string Digest { get; init; }
/// <summary>
/// Computes the digest for this report.
/// </summary>
public static string ComputeDigest(IEnumerable<DetectedEntryPoint> entryPoints)
{
var sb = new StringBuilder();
foreach (var ep in entryPoints.OrderBy(e => e.EntryId))
{
sb.Append(ep.EntryId);
sb.Append('|');
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,112 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Scanner.EntryTrace.Baseline;
/// <summary>
/// Extension methods for registering baseline analysis services.
/// </summary>
public static class BaselineServiceCollectionExtensions
{
/// <summary>
/// Adds baseline entry point analysis services to the service collection.
/// </summary>
public static IServiceCollection AddEntryTraceBaseline(this IServiceCollection services)
{
services.TryAddSingleton<IBaselineAnalyzer, BaselineAnalyzer>();
services.TryAddSingleton<IBaselineConfigProvider, DefaultBaselineConfigProvider>();
return services;
}
/// <summary>
/// Adds baseline entry point analysis with custom configurations.
/// </summary>
public static IServiceCollection AddEntryTraceBaseline(
this IServiceCollection services,
Action<BaselineAnalyzerOptions> configure)
{
services.Configure(configure);
services.TryAddSingleton<IBaselineAnalyzer, BaselineAnalyzer>();
services.TryAddSingleton<IBaselineConfigProvider, DefaultBaselineConfigProvider>();
return services;
}
}
/// <summary>
/// Options for baseline analyzer.
/// </summary>
public sealed class BaselineAnalyzerOptions
{
/// <summary>
/// Additional custom configurations to register.
/// </summary>
public List<EntryTraceBaselineConfig> CustomConfigurations { get; } = new();
/// <summary>
/// Whether to include default configurations.
/// </summary>
public bool IncludeDefaults { get; set; } = true;
/// <summary>
/// Global confidence threshold override.
/// </summary>
public double? GlobalConfidenceThreshold { get; set; }
/// <summary>
/// Global timeout in seconds.
/// </summary>
public int? GlobalTimeoutSeconds { get; set; }
}
/// <summary>
/// Provides baseline configurations.
/// </summary>
public interface IBaselineConfigProvider
{
/// <summary>
/// Gets configuration for the specified language.
/// </summary>
EntryTraceBaselineConfig? GetConfiguration(EntryTraceLanguage language);
/// <summary>
/// Gets configuration by ID.
/// </summary>
EntryTraceBaselineConfig? GetConfiguration(string configId);
/// <summary>
/// Gets all available configurations.
/// </summary>
IReadOnlyList<EntryTraceBaselineConfig> GetAllConfigurations();
}
/// <summary>
/// Default baseline configuration provider.
/// </summary>
public sealed class DefaultBaselineConfigProvider : IBaselineConfigProvider
{
private readonly Dictionary<string, EntryTraceBaselineConfig> _configsById;
private readonly Dictionary<EntryTraceLanguage, EntryTraceBaselineConfig> _configsByLanguage;
public DefaultBaselineConfigProvider()
{
var configs = DefaultConfigurations.All;
_configsById = configs.ToDictionary(c => c.ConfigId, StringComparer.OrdinalIgnoreCase);
_configsByLanguage = configs.ToDictionary(c => c.Language);
}
public EntryTraceBaselineConfig? GetConfiguration(EntryTraceLanguage language)
{
return _configsByLanguage.TryGetValue(language, out var config) ? config : null;
}
public EntryTraceBaselineConfig? GetConfiguration(string configId)
{
return _configsById.TryGetValue(configId, out var config) ? config : null;
}
public IReadOnlyList<EntryTraceBaselineConfig> GetAllConfigurations()
{
return _configsById.Values.ToList();
}
}

View File

@@ -0,0 +1,630 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.EntryTrace.Baseline;
/// <summary>
/// Provides default baseline configurations for common languages and frameworks.
/// </summary>
/// <remarks>
/// Implements SCANNER-ENTRYTRACE-18-508: Default entry point detection patterns.
/// </remarks>
public static class DefaultConfigurations
{
/// <summary>
/// Gets all default configurations.
/// </summary>
public static IReadOnlyList<EntryTraceBaselineConfig> All => new[]
{
JavaSpring,
PythonFlaskDjango,
NodeExpress,
TypeScriptNestJs,
DotNetAspNetCore,
GoGin
};
/// <summary>
/// Java Spring Boot configuration.
/// </summary>
public static EntryTraceBaselineConfig JavaSpring => new()
{
ConfigId = "java-spring-baseline",
Language = EntryTraceLanguage.Java,
Version = "1.0.0",
EntryPointPatterns = ImmutableArray.Create(
new EntryPointPattern
{
PatternId = "spring-get-mapping",
Type = PatternType.Annotation,
Pattern = @"@GetMapping\s*\(\s*[""']?(?<path>[^""'\)]+)[""']?\s*\)",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "spring"
},
new EntryPointPattern
{
PatternId = "spring-post-mapping",
Type = PatternType.Annotation,
Pattern = @"@PostMapping\s*\(\s*[""']?(?<path>[^""'\)]+)[""']?\s*\)",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "spring"
},
new EntryPointPattern
{
PatternId = "spring-put-mapping",
Type = PatternType.Annotation,
Pattern = @"@PutMapping\s*\(\s*[""']?(?<path>[^""'\)]+)[""']?\s*\)",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "spring"
},
new EntryPointPattern
{
PatternId = "spring-delete-mapping",
Type = PatternType.Annotation,
Pattern = @"@DeleteMapping\s*\(\s*[""']?(?<path>[^""'\)]+)[""']?\s*\)",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "spring"
},
new EntryPointPattern
{
PatternId = "spring-request-mapping",
Type = PatternType.Annotation,
Pattern = @"@RequestMapping\s*\([^)]*value\s*=\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "spring"
},
new EntryPointPattern
{
PatternId = "spring-scheduled",
Type = PatternType.Annotation,
Pattern = @"@Scheduled\s*\(",
Confidence = 0.95,
EntryType = EntryPointType.ScheduledJob,
Framework = "spring"
},
new EntryPointPattern
{
PatternId = "spring-kafka-listener",
Type = PatternType.Annotation,
Pattern = @"@KafkaListener\s*\(",
Confidence = 0.95,
EntryType = EntryPointType.MessageConsumer,
Framework = "spring"
},
new EntryPointPattern
{
PatternId = "spring-grpc-service",
Type = PatternType.Annotation,
Pattern = @"@GrpcService",
Confidence = 0.9,
EntryType = EntryPointType.GrpcMethod,
Framework = "spring"
}
),
FrameworkConfigs = ImmutableArray.Create(
new FrameworkConfig
{
FrameworkId = "spring-boot",
Name = "Spring Boot",
VersionRange = ">=2.0.0",
DetectionPatterns = ImmutableArray.Create(
"org.springframework.boot",
"@SpringBootApplication",
"spring-boot-starter"
),
EntryPatterns = ImmutableArray.Create(
"spring-get-mapping",
"spring-post-mapping",
"spring-put-mapping",
"spring-delete-mapping",
"spring-request-mapping",
"spring-scheduled"
),
RouterFilePatterns = ImmutableArray.Create(
"**/controller/**/*.java",
"**/rest/**/*.java",
"**/api/**/*.java"
),
ControllerPatterns = ImmutableArray.Create(
".*Controller$",
".*Resource$"
)
}
),
Exclusions = new ExclusionConfig
{
ExcludePaths = ImmutableArray.Create("**/test/**", "**/generated/**"),
ExcludePackages = ImmutableArray.Create("org.springframework.test"),
ExcludeTestFiles = true,
ExcludeGenerated = true
}
};
/// <summary>
/// Python Flask/Django configuration.
/// </summary>
public static EntryTraceBaselineConfig PythonFlaskDjango => new()
{
ConfigId = "python-web-baseline",
Language = EntryTraceLanguage.Python,
Version = "1.0.0",
EntryPointPatterns = ImmutableArray.Create(
new EntryPointPattern
{
PatternId = "flask-route",
Type = PatternType.Decorator,
Pattern = @"@(?:app|blueprint|bp)\.route\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "flask"
},
new EntryPointPattern
{
PatternId = "flask-get",
Type = PatternType.Decorator,
Pattern = @"@(?:app|blueprint|bp)\.get\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "flask"
},
new EntryPointPattern
{
PatternId = "flask-post",
Type = PatternType.Decorator,
Pattern = @"@(?:app|blueprint|bp)\.post\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "flask"
},
new EntryPointPattern
{
PatternId = "django-path",
Type = PatternType.FunctionName,
Pattern = @"path\s*\(\s*[""'](?<path>[^""']+)[""']\s*,",
Confidence = 0.85,
EntryType = EntryPointType.HttpEndpoint,
Framework = "django"
},
new EntryPointPattern
{
PatternId = "fastapi-route",
Type = PatternType.Decorator,
Pattern = @"@(?:app|router)\.(?<method>get|post|put|delete|patch)\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "fastapi"
},
new EntryPointPattern
{
PatternId = "celery-task",
Type = PatternType.Decorator,
Pattern = @"@(?:celery\.)?task\s*\(",
Confidence = 0.9,
EntryType = EntryPointType.ScheduledJob,
Framework = "celery"
}
),
FrameworkConfigs = ImmutableArray.Create(
new FrameworkConfig
{
FrameworkId = "flask",
Name = "Flask",
DetectionPatterns = ImmutableArray.Create("from flask import", "Flask(__name__)"),
EntryPatterns = ImmutableArray.Create("flask-route", "flask-get", "flask-post"),
RouterFilePatterns = ImmutableArray.Create("**/routes.py", "**/views.py", "**/api/**/*.py")
},
new FrameworkConfig
{
FrameworkId = "django",
Name = "Django",
DetectionPatterns = ImmutableArray.Create("from django", "django.conf.urls"),
EntryPatterns = ImmutableArray.Create("django-path"),
RouterFilePatterns = ImmutableArray.Create("**/urls.py", "**/views.py")
},
new FrameworkConfig
{
FrameworkId = "fastapi",
Name = "FastAPI",
DetectionPatterns = ImmutableArray.Create("from fastapi import", "FastAPI()"),
EntryPatterns = ImmutableArray.Create("fastapi-route"),
RouterFilePatterns = ImmutableArray.Create("**/routers/**/*.py", "**/api/**/*.py")
}
),
Exclusions = new ExclusionConfig
{
ExcludePaths = ImmutableArray.Create("**/test*/**", "**/migrations/**"),
ExcludeTestFiles = true,
ExcludeGenerated = true
}
};
/// <summary>
/// Node.js Express configuration.
/// </summary>
public static EntryTraceBaselineConfig NodeExpress => new()
{
ConfigId = "node-express-baseline",
Language = EntryTraceLanguage.JavaScript,
Version = "1.0.0",
EntryPointPatterns = ImmutableArray.Create(
new EntryPointPattern
{
PatternId = "express-get",
Type = PatternType.FunctionName,
Pattern = @"(?:app|router)\.get\s*\(\s*['""](?<path>[^'""]+)['""]",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "express"
},
new EntryPointPattern
{
PatternId = "express-post",
Type = PatternType.FunctionName,
Pattern = @"(?:app|router)\.post\s*\(\s*['""](?<path>[^'""]+)['""]",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "express"
},
new EntryPointPattern
{
PatternId = "express-put",
Type = PatternType.FunctionName,
Pattern = @"(?:app|router)\.put\s*\(\s*['""](?<path>[^'""]+)['""]",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "express"
},
new EntryPointPattern
{
PatternId = "express-delete",
Type = PatternType.FunctionName,
Pattern = @"(?:app|router)\.delete\s*\(\s*['""](?<path>[^'""]+)['""]",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "express"
},
new EntryPointPattern
{
PatternId = "fastify-route",
Type = PatternType.FunctionName,
Pattern = @"fastify\.(?<method>get|post|put|delete|patch)\s*\(\s*['""](?<path>[^'""]+)['""]",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "fastify"
},
new EntryPointPattern
{
PatternId = "koa-router",
Type = PatternType.FunctionName,
Pattern = @"router\.(?<method>get|post|put|delete|patch)\s*\(\s*['""](?<path>[^'""]+)['""]",
Confidence = 0.85,
EntryType = EntryPointType.HttpEndpoint,
Framework = "koa"
}
),
FrameworkConfigs = ImmutableArray.Create(
new FrameworkConfig
{
FrameworkId = "express",
Name = "Express.js",
DetectionPatterns = ImmutableArray.Create("require('express')", "from 'express'", "express()"),
EntryPatterns = ImmutableArray.Create("express-get", "express-post", "express-put", "express-delete"),
RouterFilePatterns = ImmutableArray.Create("**/routes/**/*.js", "**/api/**/*.js", "**/controllers/**/*.js")
},
new FrameworkConfig
{
FrameworkId = "fastify",
Name = "Fastify",
DetectionPatterns = ImmutableArray.Create("require('fastify')", "from 'fastify'"),
EntryPatterns = ImmutableArray.Create("fastify-route")
}
),
Exclusions = new ExclusionConfig
{
ExcludePaths = ImmutableArray.Create("**/node_modules/**", "**/dist/**", "**/build/**"),
ExcludeTestFiles = true,
ExcludeGenerated = true
}
};
/// <summary>
/// TypeScript NestJS configuration.
/// </summary>
public static EntryTraceBaselineConfig TypeScriptNestJs => new()
{
ConfigId = "typescript-nestjs-baseline",
Language = EntryTraceLanguage.TypeScript,
Version = "1.0.0",
EntryPointPatterns = ImmutableArray.Create(
new EntryPointPattern
{
PatternId = "nestjs-get",
Type = PatternType.Decorator,
Pattern = @"@Get\s*\(\s*['""]?(?<path>[^'"")\s]*)['""]?\s*\)",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "nestjs"
},
new EntryPointPattern
{
PatternId = "nestjs-post",
Type = PatternType.Decorator,
Pattern = @"@Post\s*\(\s*['""]?(?<path>[^'"")\s]*)['""]?\s*\)",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "nestjs"
},
new EntryPointPattern
{
PatternId = "nestjs-put",
Type = PatternType.Decorator,
Pattern = @"@Put\s*\(\s*['""]?(?<path>[^'"")\s]*)['""]?\s*\)",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "nestjs"
},
new EntryPointPattern
{
PatternId = "nestjs-delete",
Type = PatternType.Decorator,
Pattern = @"@Delete\s*\(\s*['""]?(?<path>[^'"")\s]*)['""]?\s*\)",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "nestjs"
},
new EntryPointPattern
{
PatternId = "nestjs-message-pattern",
Type = PatternType.Decorator,
Pattern = @"@MessagePattern\s*\(",
Confidence = 0.9,
EntryType = EntryPointType.MessageConsumer,
Framework = "nestjs"
},
new EntryPointPattern
{
PatternId = "nestjs-event-pattern",
Type = PatternType.Decorator,
Pattern = @"@EventPattern\s*\(",
Confidence = 0.9,
EntryType = EntryPointType.EventHandler,
Framework = "nestjs"
},
new EntryPointPattern
{
PatternId = "nestjs-grpc-method",
Type = PatternType.Decorator,
Pattern = @"@GrpcMethod\s*\(",
Confidence = 0.95,
EntryType = EntryPointType.GrpcMethod,
Framework = "nestjs"
}
),
FrameworkConfigs = ImmutableArray.Create(
new FrameworkConfig
{
FrameworkId = "nestjs",
Name = "NestJS",
DetectionPatterns = ImmutableArray.Create("@nestjs/common", "@Controller", "@Injectable"),
EntryPatterns = ImmutableArray.Create(
"nestjs-get", "nestjs-post", "nestjs-put", "nestjs-delete",
"nestjs-message-pattern", "nestjs-event-pattern", "nestjs-grpc-method"
),
RouterFilePatterns = ImmutableArray.Create("**/*.controller.ts"),
ControllerPatterns = ImmutableArray.Create(".*Controller$")
}
),
Exclusions = new ExclusionConfig
{
ExcludePaths = ImmutableArray.Create("**/node_modules/**", "**/dist/**"),
ExcludeTestFiles = true,
ExcludeGenerated = true
}
};
/// <summary>
/// .NET ASP.NET Core configuration.
/// </summary>
public static EntryTraceBaselineConfig DotNetAspNetCore => new()
{
ConfigId = "dotnet-aspnet-baseline",
Language = EntryTraceLanguage.CSharp,
Version = "1.0.0",
EntryPointPatterns = ImmutableArray.Create(
new EntryPointPattern
{
PatternId = "aspnet-httpget",
Type = PatternType.Annotation,
Pattern = @"\[HttpGet\s*\(\s*[""']?(?<path>[^""'\]]*)[""']?\s*\)\]",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "aspnet"
},
new EntryPointPattern
{
PatternId = "aspnet-httppost",
Type = PatternType.Annotation,
Pattern = @"\[HttpPost\s*\(\s*[""']?(?<path>[^""'\]]*)[""']?\s*\)\]",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "aspnet"
},
new EntryPointPattern
{
PatternId = "aspnet-httpput",
Type = PatternType.Annotation,
Pattern = @"\[HttpPut\s*\(\s*[""']?(?<path>[^""'\]]*)[""']?\s*\)\]",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "aspnet"
},
new EntryPointPattern
{
PatternId = "aspnet-httpdelete",
Type = PatternType.Annotation,
Pattern = @"\[HttpDelete\s*\(\s*[""']?(?<path>[^""'\]]*)[""']?\s*\)\]",
Confidence = 0.95,
EntryType = EntryPointType.HttpEndpoint,
Framework = "aspnet"
},
new EntryPointPattern
{
PatternId = "aspnet-route",
Type = PatternType.Annotation,
Pattern = @"\[Route\s*\(\s*[""'](?<path>[^""']+)[""']\s*\)\]",
Confidence = 0.85,
EntryType = EntryPointType.HttpEndpoint,
Framework = "aspnet"
},
new EntryPointPattern
{
PatternId = "aspnet-minimal-map",
Type = PatternType.FunctionName,
Pattern = @"(?:app|endpoints)\.Map(?<method>Get|Post|Put|Delete|Patch)\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "aspnet-minimal"
},
new EntryPointPattern
{
PatternId = "grpc-service",
Type = PatternType.ClassName,
Pattern = @"class\s+\w+\s*:\s*\w+\.(\w+)Base\b",
Confidence = 0.85,
EntryType = EntryPointType.GrpcMethod,
Framework = "grpc"
}
),
FrameworkConfigs = ImmutableArray.Create(
new FrameworkConfig
{
FrameworkId = "aspnet",
Name = "ASP.NET Core",
DetectionPatterns = ImmutableArray.Create(
"Microsoft.AspNetCore",
"ControllerBase",
"[ApiController]"
),
EntryPatterns = ImmutableArray.Create(
"aspnet-httpget", "aspnet-httppost", "aspnet-httpput",
"aspnet-httpdelete", "aspnet-route", "aspnet-minimal-map"
),
RouterFilePatterns = ImmutableArray.Create("**/*Controller.cs", "**/Controllers/**/*.cs"),
ControllerPatterns = ImmutableArray.Create(".*Controller$")
}
),
Exclusions = new ExclusionConfig
{
ExcludePaths = ImmutableArray.Create("**/bin/**", "**/obj/**", "**/Migrations/**"),
ExcludeTestFiles = true,
ExcludeGenerated = true
}
};
/// <summary>
/// Go Gin/Echo configuration.
/// </summary>
public static EntryTraceBaselineConfig GoGin => new()
{
ConfigId = "go-web-baseline",
Language = EntryTraceLanguage.Go,
Version = "1.0.0",
EntryPointPatterns = ImmutableArray.Create(
new EntryPointPattern
{
PatternId = "gin-route",
Type = PatternType.FunctionName,
Pattern = @"(?:r|router|g|group)\.(?<method>GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "gin"
},
new EntryPointPattern
{
PatternId = "echo-route",
Type = PatternType.FunctionName,
Pattern = @"e\.(?<method>GET|POST|PUT|DELETE|PATCH)\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "echo"
},
new EntryPointPattern
{
PatternId = "chi-route",
Type = PatternType.FunctionName,
Pattern = @"r\.(?<method>Get|Post|Put|Delete|Patch)\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.9,
EntryType = EntryPointType.HttpEndpoint,
Framework = "chi"
},
new EntryPointPattern
{
PatternId = "http-handle",
Type = PatternType.FunctionName,
Pattern = @"http\.Handle(?:Func)?\s*\(\s*[""'](?<path>[^""']+)[""']",
Confidence = 0.8,
EntryType = EntryPointType.HttpEndpoint,
Framework = "net/http"
},
new EntryPointPattern
{
PatternId = "grpc-register",
Type = PatternType.FunctionName,
Pattern = @"Register\w+Server\s*\(",
Confidence = 0.85,
EntryType = EntryPointType.GrpcMethod,
Framework = "grpc"
}
),
FrameworkConfigs = ImmutableArray.Create(
new FrameworkConfig
{
FrameworkId = "gin",
Name = "Gin",
DetectionPatterns = ImmutableArray.Create("github.com/gin-gonic/gin", "gin.Default()", "gin.New()"),
EntryPatterns = ImmutableArray.Create("gin-route")
},
new FrameworkConfig
{
FrameworkId = "echo",
Name = "Echo",
DetectionPatterns = ImmutableArray.Create("github.com/labstack/echo", "echo.New()"),
EntryPatterns = ImmutableArray.Create("echo-route")
},
new FrameworkConfig
{
FrameworkId = "chi",
Name = "Chi",
DetectionPatterns = ImmutableArray.Create("github.com/go-chi/chi"),
EntryPatterns = ImmutableArray.Create("chi-route")
}
),
Exclusions = new ExclusionConfig
{
ExcludePaths = ImmutableArray.Create("**/vendor/**", "**/testdata/**"),
ExcludeTestFiles = true,
ExcludeGenerated = true
}
};
/// <summary>
/// Gets configuration for a specific language.
/// </summary>
public static EntryTraceBaselineConfig? GetForLanguage(EntryTraceLanguage language)
{
return language switch
{
EntryTraceLanguage.Java => JavaSpring,
EntryTraceLanguage.Python => PythonFlaskDjango,
EntryTraceLanguage.JavaScript => NodeExpress,
EntryTraceLanguage.TypeScript => TypeScriptNestJs,
EntryTraceLanguage.CSharp => DotNetAspNetCore,
EntryTraceLanguage.Go => GoGin,
_ => null
};
}
}

View File

@@ -19,10 +19,22 @@ public static class BoundaryServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddBoundaryExtractors(this IServiceCollection services)
{
// Register base extractor
// Register base extractor (Priority 100 - fallback)
services.TryAddSingleton<RichGraphBoundaryExtractor>();
services.TryAddSingleton<IBoundaryProofExtractor, RichGraphBoundaryExtractor>();
// Register IaC extractor (Priority 150 - for Terraform/CloudFormation/Pulumi/Helm sources)
services.TryAddSingleton<IacBoundaryExtractor>();
services.AddSingleton<IBoundaryProofExtractor, IacBoundaryExtractor>();
// Register K8s extractor (Priority 200 - higher precedence for K8s sources)
services.TryAddSingleton<K8sBoundaryExtractor>();
services.AddSingleton<IBoundaryProofExtractor, K8sBoundaryExtractor>();
// Register Gateway extractor (Priority 250 - higher precedence for API gateway sources)
services.TryAddSingleton<GatewayBoundaryExtractor>();
services.AddSingleton<IBoundaryProofExtractor, GatewayBoundaryExtractor>();
// Register composite extractor that uses all available extractors
services.TryAddSingleton<CompositeBoundaryExtractor>();

View File

@@ -0,0 +1,769 @@
// -----------------------------------------------------------------------------
// GatewayBoundaryExtractor.cs
// Sprint: SPRINT_3800_0002_0003_boundary_gateway
// Description: Extracts boundary proof from API Gateway metadata.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extracts boundary proof from API Gateway deployment metadata.
/// Parses Kong, Envoy/Istio, AWS API Gateway, and Traefik configurations.
/// </summary>
public sealed class GatewayBoundaryExtractor : IBoundaryProofExtractor
{
private readonly ILogger<GatewayBoundaryExtractor> _logger;
private readonly TimeProvider _timeProvider;
// Gateway source identifiers
private static readonly string[] GatewaySources =
[
"gateway",
"kong",
"envoy",
"istio",
"apigateway",
"traefik"
];
// Gateway annotation prefixes
private static readonly string[] GatewayAnnotationPrefixes =
[
"kong.",
"konghq.com/",
"envoy.",
"istio.io/",
"apigateway.",
"traefik.",
"getambassador.io/"
];
public GatewayBoundaryExtractor(
ILogger<GatewayBoundaryExtractor> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public int Priority => 250; // Higher than K8sBoundaryExtractor (200)
/// <inheritdoc />
public bool CanHandle(BoundaryExtractionContext context)
{
// Handle when source is a known gateway
if (GatewaySources.Any(s =>
string.Equals(context.Source, s, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
// Also handle if annotations contain gateway-specific keys
return context.Annotations.Keys.Any(k =>
GatewayAnnotationPrefixes.Any(prefix =>
k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)));
}
/// <inheritdoc />
public Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Extract(root, rootNode, context));
}
/// <inheritdoc />
public BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
ArgumentNullException.ThrowIfNull(root);
if (!CanHandle(context))
{
return null;
}
try
{
var annotations = context.Annotations;
var gatewayType = DetectGatewayType(context);
var exposure = DetermineExposure(context, gatewayType);
var surface = DetermineSurface(context, annotations, gatewayType);
var auth = DetectAuth(annotations, gatewayType);
var controls = DetectControls(annotations, gatewayType);
var confidence = CalculateConfidence(annotations, gatewayType);
_logger.LogDebug(
"Gateway boundary extraction: gateway={Gateway}, exposure={ExposureLevel}, confidence={Confidence:F2}",
gatewayType,
exposure.Level,
confidence);
return new BoundaryProof
{
Kind = "network",
Surface = surface,
Exposure = exposure,
Auth = auth,
Controls = controls.Count > 0 ? controls : null,
LastSeen = _timeProvider.GetUtcNow(),
Confidence = confidence,
Source = $"gateway:{gatewayType}",
EvidenceRef = BuildEvidenceRef(context, root.Id, gatewayType)
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Gateway boundary extraction failed for root {RootId}", root.Id);
return null;
}
}
private string DetectGatewayType(BoundaryExtractionContext context)
{
var source = context.Source?.ToLowerInvariant() ?? string.Empty;
var annotations = context.Annotations;
// Check source first
if (source.Contains("kong"))
return "kong";
if (source.Contains("envoy") || source.Contains("istio"))
return "envoy";
if (source.Contains("apigateway"))
return "aws-apigw";
if (source.Contains("traefik"))
return "traefik";
// Check annotations
if (annotations.Keys.Any(k => k.StartsWith("kong.", StringComparison.OrdinalIgnoreCase) ||
k.StartsWith("konghq.com/", StringComparison.OrdinalIgnoreCase)))
return "kong";
if (annotations.Keys.Any(k => k.StartsWith("istio.io/", StringComparison.OrdinalIgnoreCase) ||
k.StartsWith("envoy.", StringComparison.OrdinalIgnoreCase)))
return "envoy";
if (annotations.Keys.Any(k => k.StartsWith("apigateway.", StringComparison.OrdinalIgnoreCase)))
return "aws-apigw";
if (annotations.Keys.Any(k => k.StartsWith("traefik.", StringComparison.OrdinalIgnoreCase)))
return "traefik";
if (annotations.Keys.Any(k => k.StartsWith("getambassador.io/", StringComparison.OrdinalIgnoreCase)))
return "ambassador";
return "generic";
}
private BoundaryExposure DetermineExposure(BoundaryExtractionContext context, string gatewayType)
{
var annotations = context.Annotations;
var level = "public"; // API gateways are typically internet-facing
var internetFacing = true;
var behindProxy = true; // Gateway is the proxy
List<string>? clientTypes = ["browser", "api_client", "mobile"];
// Check for internal-only configurations
if (annotations.TryGetValue($"{gatewayType}.internal", out var isInternal) ||
annotations.TryGetValue("internal", out isInternal))
{
if (bool.TryParse(isInternal, out var internalFlag) && internalFlag)
{
level = "internal";
internetFacing = false;
clientTypes = ["service"];
}
}
// Istio mesh internal
if (gatewayType == "envoy" &&
annotations.Keys.Any(k => k.Contains("mesh", StringComparison.OrdinalIgnoreCase)))
{
level = "internal";
internetFacing = false;
clientTypes = ["service"];
}
// AWS internal API
if (gatewayType == "aws-apigw" &&
annotations.TryGetValue("apigateway.endpoint-type", out var endpointType))
{
if (endpointType.Equals("PRIVATE", StringComparison.OrdinalIgnoreCase))
{
level = "internal";
internetFacing = false;
clientTypes = ["service"];
}
}
return new BoundaryExposure
{
Level = level,
InternetFacing = internetFacing,
Zone = context.NetworkZone,
BehindProxy = behindProxy,
ClientTypes = clientTypes
};
}
private BoundarySurface DetermineSurface(
BoundaryExtractionContext context,
IReadOnlyDictionary<string, string> annotations,
string gatewayType)
{
string? path = null;
string protocol = "https";
int? port = null;
string? host = null;
// Gateway-specific path extraction
path = gatewayType switch
{
"kong" => TryGetAnnotation(annotations, "kong.route.path", "kong.path", "konghq.com/path"),
"envoy" => TryGetAnnotation(annotations, "envoy.route.prefix", "istio.io/path"),
"aws-apigw" => TryGetAnnotation(annotations, "apigateway.path", "apigateway.resource-path"),
"traefik" => TryGetAnnotation(annotations, "traefik.http.routers.path", "traefik.path"),
_ => TryGetAnnotation(annotations, "path", "route.path")
};
// Default path from namespace
path ??= !string.IsNullOrEmpty(context.Namespace) ? $"/{context.Namespace}" : "/";
// Host extraction
host = gatewayType switch
{
"kong" => TryGetAnnotation(annotations, "kong.route.host", "konghq.com/host"),
"envoy" => TryGetAnnotation(annotations, "istio.io/host", "envoy.virtual-host"),
"aws-apigw" => TryGetAnnotation(annotations, "apigateway.domain-name"),
"traefik" => TryGetAnnotation(annotations, "traefik.http.routers.host"),
_ => TryGetAnnotation(annotations, "host")
};
// Protocol - gateways typically use HTTPS
if (annotations.Keys.Any(k => k.Contains("grpc", StringComparison.OrdinalIgnoreCase)))
{
protocol = "grpc";
}
else if (annotations.Keys.Any(k => k.Contains("websocket", StringComparison.OrdinalIgnoreCase)))
{
protocol = "wss";
}
// Port from bindings
if (context.PortBindings.Count > 0)
{
port = context.PortBindings.Keys.FirstOrDefault();
}
else
{
// Default gateway ports
port = protocol switch
{
"https" => 443,
"grpc" => 443,
"wss" => 443,
_ => 80
};
}
return new BoundarySurface
{
Type = "api",
Protocol = protocol,
Port = port,
Host = host,
Path = path
};
}
private BoundaryAuth? DetectAuth(
IReadOnlyDictionary<string, string> annotations,
string gatewayType)
{
string? authType = null;
var required = false;
List<string>? roles = null;
string? provider = null;
bool? mfaRequired = null;
switch (gatewayType)
{
case "kong":
(authType, required, roles, provider) = DetectKongAuth(annotations);
break;
case "envoy":
(authType, required, roles, provider) = DetectEnvoyAuth(annotations);
break;
case "aws-apigw":
(authType, required, roles, provider) = DetectAwsApigwAuth(annotations);
break;
case "traefik":
(authType, required, roles, provider) = DetectTraefikAuth(annotations);
break;
default:
(authType, required, roles, provider) = DetectGenericAuth(annotations);
break;
}
if (!required)
{
return null;
}
return new BoundaryAuth
{
Required = required,
Type = authType,
Roles = roles,
Provider = provider,
MfaRequired = mfaRequired
};
}
private static (string? authType, bool required, List<string>? roles, string? provider) DetectKongAuth(
IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
List<string>? roles = null;
string? provider = null;
foreach (var (key, value) in annotations)
{
// JWT plugin
if (key.Contains("jwt", StringComparison.OrdinalIgnoreCase) &&
(key.Contains("plugin", StringComparison.OrdinalIgnoreCase) ||
key.Contains("kong", StringComparison.OrdinalIgnoreCase)))
{
authType = "jwt";
required = true;
}
// OAuth2 plugin
if (key.Contains("oauth2", StringComparison.OrdinalIgnoreCase))
{
authType = "oauth2";
required = true;
}
// Key-auth plugin
if (key.Contains("key-auth", StringComparison.OrdinalIgnoreCase))
{
authType = "api_key";
required = true;
}
// Basic auth plugin
if (key.Contains("basic-auth", StringComparison.OrdinalIgnoreCase))
{
authType = "basic";
required = true;
}
// ACL plugin for roles
if (key.Contains("acl", StringComparison.OrdinalIgnoreCase) &&
key.Contains("allow", StringComparison.OrdinalIgnoreCase))
{
roles = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
}
return (authType, required, roles, provider);
}
private static (string? authType, bool required, List<string>? roles, string? provider) DetectEnvoyAuth(
IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
List<string>? roles = null;
string? provider = null;
foreach (var (key, value) in annotations)
{
// Istio JWT policy
if (key.Contains("jwt", StringComparison.OrdinalIgnoreCase) ||
key.Contains("requestauthentication", StringComparison.OrdinalIgnoreCase))
{
authType = "jwt";
required = true;
}
// Istio AuthorizationPolicy
if (key.Contains("authorizationpolicy", StringComparison.OrdinalIgnoreCase))
{
authType ??= "rbac";
required = true;
}
// mTLS
if (key.Contains("mtls", StringComparison.OrdinalIgnoreCase) ||
key.Contains("peerauthentication", StringComparison.OrdinalIgnoreCase))
{
authType = "mtls";
required = true;
}
// OIDC filter
if (key.Contains("oidc", StringComparison.OrdinalIgnoreCase))
{
authType = "oauth2";
required = true;
if (value.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
provider = value;
}
}
}
return (authType, required, roles, provider);
}
private static (string? authType, bool required, List<string>? roles, string? provider) DetectAwsApigwAuth(
IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
List<string>? roles = null;
string? provider = null;
foreach (var (key, value) in annotations)
{
// Cognito authorizer
if (key.Contains("cognito", StringComparison.OrdinalIgnoreCase))
{
authType = "oauth2";
required = true;
provider = "cognito";
}
// Lambda authorizer
if (key.Contains("lambda-authorizer", StringComparison.OrdinalIgnoreCase) ||
(key.Contains("authorizer", StringComparison.OrdinalIgnoreCase) &&
value.Contains("lambda", StringComparison.OrdinalIgnoreCase)))
{
authType = "custom";
required = true;
provider = "lambda";
}
// API key required
if (key.Contains("api-key-required", StringComparison.OrdinalIgnoreCase))
{
if (bool.TryParse(value, out var keyRequired) && keyRequired)
{
authType = "api_key";
required = true;
}
}
// IAM authorizer
if (key.Contains("iam", StringComparison.OrdinalIgnoreCase) &&
key.Contains("authorizer", StringComparison.OrdinalIgnoreCase))
{
authType = "iam";
required = true;
provider = "aws-iam";
}
}
return (authType, required, roles, provider);
}
private static (string? authType, bool required, List<string>? roles, string? provider) DetectTraefikAuth(
IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
List<string>? roles = null;
string? provider = null;
foreach (var (key, value) in annotations)
{
// Basic auth middleware
if (key.Contains("basicauth", StringComparison.OrdinalIgnoreCase))
{
authType = "basic";
required = true;
}
// Digest auth middleware
if (key.Contains("digestauth", StringComparison.OrdinalIgnoreCase))
{
authType = "digest";
required = true;
}
// Forward auth middleware (external auth)
if (key.Contains("forwardauth", StringComparison.OrdinalIgnoreCase))
{
authType = "custom";
required = true;
if (value.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
provider = value;
}
}
// OAuth middleware plugin
if (key.Contains("oauth", StringComparison.OrdinalIgnoreCase))
{
authType = "oauth2";
required = true;
}
}
return (authType, required, roles, provider);
}
private static (string? authType, bool required, List<string>? roles, string? provider) DetectGenericAuth(
IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
List<string>? roles = null;
string? provider = null;
foreach (var (key, value) in annotations)
{
if (key.Contains("auth", StringComparison.OrdinalIgnoreCase))
{
if (key.Contains("jwt", StringComparison.OrdinalIgnoreCase))
authType = "jwt";
else if (key.Contains("oauth", StringComparison.OrdinalIgnoreCase))
authType = "oauth2";
else if (key.Contains("basic", StringComparison.OrdinalIgnoreCase))
authType = "basic";
else if (key.Contains("api-key", StringComparison.OrdinalIgnoreCase))
authType = "api_key";
else
authType = "custom";
required = true;
}
}
return (authType, required, roles, provider);
}
private List<BoundaryControl> DetectControls(
IReadOnlyDictionary<string, string> annotations,
string gatewayType)
{
var controls = new List<BoundaryControl>();
var now = _timeProvider.GetUtcNow();
// Rate limiting
var hasRateLimit = annotations.Keys.Any(k =>
k.Contains("rate-limit", StringComparison.OrdinalIgnoreCase) ||
k.Contains("ratelimit", StringComparison.OrdinalIgnoreCase) ||
k.Contains("throttle", StringComparison.OrdinalIgnoreCase) ||
// Kong
k.Contains("kong.plugin.rate-limiting", StringComparison.OrdinalIgnoreCase) ||
// Istio
k.Contains("ratelimit.config", StringComparison.OrdinalIgnoreCase) ||
// AWS
k.Contains("apigateway.throttle", StringComparison.OrdinalIgnoreCase));
if (hasRateLimit)
{
controls.Add(new BoundaryControl
{
Type = "rate_limit",
Active = true,
Config = gatewayType,
Effectiveness = "medium",
VerifiedAt = now
});
}
// IP restrictions
var hasIpRestriction = annotations.Keys.Any(k =>
k.Contains("ip-restriction", StringComparison.OrdinalIgnoreCase) ||
k.Contains("whitelist", StringComparison.OrdinalIgnoreCase) ||
k.Contains("allowlist", StringComparison.OrdinalIgnoreCase) ||
k.Contains("blacklist", StringComparison.OrdinalIgnoreCase) ||
k.Contains("denylist", StringComparison.OrdinalIgnoreCase));
if (hasIpRestriction)
{
controls.Add(new BoundaryControl
{
Type = "ip_allowlist",
Active = true,
Config = gatewayType,
Effectiveness = "high",
VerifiedAt = now
});
}
// CORS
var hasCors = annotations.Keys.Any(k =>
k.Contains("cors", StringComparison.OrdinalIgnoreCase));
if (hasCors)
{
controls.Add(new BoundaryControl
{
Type = "cors",
Active = true,
Config = gatewayType,
Effectiveness = "low",
VerifiedAt = now
});
}
// Request size limit
var hasSizeLimit = annotations.Keys.Any(k =>
k.Contains("request-size", StringComparison.OrdinalIgnoreCase) ||
k.Contains("body-limit", StringComparison.OrdinalIgnoreCase) ||
k.Contains("max-body", StringComparison.OrdinalIgnoreCase));
if (hasSizeLimit)
{
controls.Add(new BoundaryControl
{
Type = "request_size_limit",
Active = true,
Config = gatewayType,
Effectiveness = "low",
VerifiedAt = now
});
}
// WAF / Bot protection
var hasWaf = annotations.Keys.Any(k =>
k.Contains("waf", StringComparison.OrdinalIgnoreCase) ||
k.Contains("bot", StringComparison.OrdinalIgnoreCase) ||
k.Contains("modsecurity", StringComparison.OrdinalIgnoreCase) ||
// Kong
k.Contains("kong.plugin.bot-detection", StringComparison.OrdinalIgnoreCase) ||
// AWS
k.Contains("apigateway.waf", StringComparison.OrdinalIgnoreCase));
if (hasWaf)
{
controls.Add(new BoundaryControl
{
Type = "waf",
Active = true,
Config = gatewayType,
Effectiveness = "high",
VerifiedAt = now
});
}
// Request transformation / validation
var hasValidation = annotations.Keys.Any(k =>
k.Contains("request-validation", StringComparison.OrdinalIgnoreCase) ||
k.Contains("request-transformer", StringComparison.OrdinalIgnoreCase) ||
k.Contains("validate", StringComparison.OrdinalIgnoreCase));
if (hasValidation)
{
controls.Add(new BoundaryControl
{
Type = "input_validation",
Active = true,
Config = gatewayType,
Effectiveness = "medium",
VerifiedAt = now
});
}
return controls;
}
private static double CalculateConfidence(
IReadOnlyDictionary<string, string> annotations,
string gatewayType)
{
// Base confidence from gateway source
var confidence = 0.75;
// Higher confidence if we have specific gateway annotations
if (gatewayType != "generic")
{
confidence += 0.1;
}
// Higher confidence if we have auth information
if (annotations.Keys.Any(k =>
k.Contains("auth", StringComparison.OrdinalIgnoreCase) ||
k.Contains("jwt", StringComparison.OrdinalIgnoreCase) ||
k.Contains("oauth", StringComparison.OrdinalIgnoreCase)))
{
confidence += 0.05;
}
// Higher confidence if we have routing information
if (annotations.Keys.Any(k =>
k.Contains("route", StringComparison.OrdinalIgnoreCase) ||
k.Contains("path", StringComparison.OrdinalIgnoreCase) ||
k.Contains("host", StringComparison.OrdinalIgnoreCase)))
{
confidence += 0.05;
}
// Cap at 0.95
return Math.Min(confidence, 0.95);
}
private static string? TryGetAnnotation(
IReadOnlyDictionary<string, string> annotations,
params string[] keys)
{
foreach (var key in keys)
{
if (annotations.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
// Also try case-insensitive match
var match = annotations.FirstOrDefault(kv =>
kv.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(match.Value))
{
return match.Value;
}
}
return null;
}
private static string BuildEvidenceRef(
BoundaryExtractionContext context,
string rootId,
string gatewayType)
{
var parts = new List<string> { "gateway", gatewayType };
if (!string.IsNullOrEmpty(context.Namespace))
{
parts.Add(context.Namespace);
}
if (!string.IsNullOrEmpty(context.EnvironmentId))
{
parts.Add(context.EnvironmentId);
}
parts.Add(rootId);
return string.Join("/", parts);
}
}

View File

@@ -0,0 +1,838 @@
// -----------------------------------------------------------------------------
// IacBoundaryExtractor.cs
// Sprint: SPRINT_3800_0002_0004_boundary_iac
// Description: Extracts boundary proof from Infrastructure-as-Code metadata.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extracts boundary proof from Infrastructure-as-Code configurations.
/// Parses Terraform, CloudFormation, Pulumi, and Helm chart configurations.
/// </summary>
public sealed class IacBoundaryExtractor : IBoundaryProofExtractor
{
private readonly ILogger<IacBoundaryExtractor> _logger;
private readonly TimeProvider _timeProvider;
// IaC source identifiers
private static readonly string[] IacSources =
[
"terraform",
"cloudformation",
"cfn",
"pulumi",
"helm",
"iac",
"infrastructure"
];
// IaC annotation prefixes
private static readonly string[] IacAnnotationPrefixes =
[
"terraform.",
"cloudformation.",
"cfn.",
"pulumi.",
"helm.",
"aws::",
"azure.",
"gcp."
];
public IacBoundaryExtractor(
ILogger<IacBoundaryExtractor> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public int Priority => 150; // Between base (100) and K8s (200) - IaC is declarative intent
/// <inheritdoc />
public bool CanHandle(BoundaryExtractionContext context)
{
// Handle when source is a known IaC tool
if (IacSources.Any(s =>
string.Equals(context.Source, s, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
// Also handle if annotations contain IaC-specific keys
return context.Annotations.Keys.Any(k =>
IacAnnotationPrefixes.Any(prefix =>
k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)));
}
/// <inheritdoc />
public Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Extract(root, rootNode, context));
}
/// <inheritdoc />
public BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
ArgumentNullException.ThrowIfNull(root);
if (!CanHandle(context))
{
return null;
}
try
{
var annotations = context.Annotations;
var iacType = DetectIacType(context);
var exposure = DetermineExposure(context, annotations, iacType);
var surface = DetermineSurface(context, annotations, iacType);
var auth = DetectAuth(annotations, iacType);
var controls = DetectControls(annotations, iacType);
var confidence = CalculateConfidence(annotations, iacType);
_logger.LogDebug(
"IaC boundary extraction: iac={IacType}, exposure={ExposureLevel}, confidence={Confidence:F2}",
iacType,
exposure.Level,
confidence);
return new BoundaryProof
{
Kind = "network",
Surface = surface,
Exposure = exposure,
Auth = auth,
Controls = controls.Count > 0 ? controls : null,
LastSeen = _timeProvider.GetUtcNow(),
Confidence = confidence,
Source = $"iac:{iacType}",
EvidenceRef = BuildEvidenceRef(context, root.Id, iacType)
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "IaC boundary extraction failed for root {RootId}", root.Id);
return null;
}
}
private string DetectIacType(BoundaryExtractionContext context)
{
var source = context.Source?.ToLowerInvariant() ?? string.Empty;
var annotations = context.Annotations;
// Check source first
if (source.Contains("terraform"))
return "terraform";
if (source.Contains("cloudformation") || source.Contains("cfn"))
return "cloudformation";
if (source.Contains("pulumi"))
return "pulumi";
if (source.Contains("helm"))
return "helm";
// Check annotations
if (annotations.Keys.Any(k => k.StartsWith("terraform.", StringComparison.OrdinalIgnoreCase)))
return "terraform";
if (annotations.Keys.Any(k =>
k.StartsWith("cloudformation.", StringComparison.OrdinalIgnoreCase) ||
k.StartsWith("cfn.", StringComparison.OrdinalIgnoreCase) ||
k.Contains("AWS::", StringComparison.Ordinal)))
return "cloudformation";
if (annotations.Keys.Any(k => k.StartsWith("pulumi.", StringComparison.OrdinalIgnoreCase)))
return "pulumi";
if (annotations.Keys.Any(k => k.StartsWith("helm.", StringComparison.OrdinalIgnoreCase)))
return "helm";
// Check for cloud provider patterns
if (annotations.Keys.Any(k => k.StartsWith("aws:", StringComparison.OrdinalIgnoreCase)))
return "terraform"; // Assume Terraform for AWS resources
if (annotations.Keys.Any(k => k.StartsWith("azure.", StringComparison.OrdinalIgnoreCase)))
return "terraform";
if (annotations.Keys.Any(k => k.StartsWith("gcp.", StringComparison.OrdinalIgnoreCase)))
return "terraform";
return "generic";
}
private BoundaryExposure DetermineExposure(
BoundaryExtractionContext context,
IReadOnlyDictionary<string, string> annotations,
string iacType)
{
var level = "private";
var internetFacing = false;
var behindProxy = false;
List<string>? clientTypes = ["service"];
// Check for public internet exposure indicators
var hasPublicExposure = false;
switch (iacType)
{
case "terraform":
hasPublicExposure = DetectTerraformPublicExposure(annotations);
break;
case "cloudformation":
hasPublicExposure = DetectCloudFormationPublicExposure(annotations);
break;
case "pulumi":
hasPublicExposure = DetectPulumiPublicExposure(annotations);
break;
case "helm":
hasPublicExposure = DetectHelmPublicExposure(annotations);
break;
default:
hasPublicExposure = DetectGenericPublicExposure(annotations);
break;
}
if (hasPublicExposure || context.IsInternetFacing == true)
{
level = "public";
internetFacing = true;
clientTypes = ["browser", "api_client"];
}
else if (annotations.Keys.Any(k =>
k.Contains("internal", StringComparison.OrdinalIgnoreCase) ||
k.Contains("private", StringComparison.OrdinalIgnoreCase)))
{
level = "internal";
clientTypes = ["service"];
}
// Check for load balancer (implies behind proxy)
if (annotations.Keys.Any(k =>
k.Contains("load_balancer", StringComparison.OrdinalIgnoreCase) ||
k.Contains("loadbalancer", StringComparison.OrdinalIgnoreCase) ||
k.Contains("alb", StringComparison.OrdinalIgnoreCase) ||
k.Contains("elb", StringComparison.OrdinalIgnoreCase)))
{
behindProxy = true;
}
return new BoundaryExposure
{
Level = level,
InternetFacing = internetFacing,
Zone = context.NetworkZone,
BehindProxy = behindProxy,
ClientTypes = clientTypes
};
}
private static bool DetectTerraformPublicExposure(IReadOnlyDictionary<string, string> annotations)
{
// Check for internet-facing resources
foreach (var (key, value) in annotations)
{
// Security group with 0.0.0.0/0
if (key.Contains("security_group", StringComparison.OrdinalIgnoreCase) &&
key.Contains("ingress", StringComparison.OrdinalIgnoreCase))
{
if (value.Contains("0.0.0.0/0") || value.Contains("::/0"))
return true;
}
// Internet-facing ALB
if (key.Contains("aws_lb", StringComparison.OrdinalIgnoreCase) &&
key.Contains("internal", StringComparison.OrdinalIgnoreCase))
{
if (value.Equals("false", StringComparison.OrdinalIgnoreCase))
return true;
}
// Public subnet
if (key.Contains("map_public_ip", StringComparison.OrdinalIgnoreCase))
{
if (value.Equals("true", StringComparison.OrdinalIgnoreCase))
return true;
}
// Public IP association
if (key.Contains("public_ip", StringComparison.OrdinalIgnoreCase) ||
key.Contains("eip", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool DetectCloudFormationPublicExposure(IReadOnlyDictionary<string, string> annotations)
{
foreach (var (key, value) in annotations)
{
// Security group with public CIDR
if (key.Contains("SecurityGroup", StringComparison.OrdinalIgnoreCase))
{
if (value.Contains("0.0.0.0/0") || value.Contains("::/0"))
return true;
}
// Internet-facing ELB/ALB
if ((key.Contains("LoadBalancer", StringComparison.OrdinalIgnoreCase) ||
key.Contains("ELB", StringComparison.OrdinalIgnoreCase)) &&
key.Contains("Scheme", StringComparison.OrdinalIgnoreCase))
{
if (value.Equals("internet-facing", StringComparison.OrdinalIgnoreCase))
return true;
}
// API Gateway
if (key.Contains("ApiGateway", StringComparison.OrdinalIgnoreCase))
{
return true;
}
// CloudFront distribution
if (key.Contains("CloudFront", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool DetectPulumiPublicExposure(IReadOnlyDictionary<string, string> annotations)
{
foreach (var (key, value) in annotations)
{
// Public security group rule
if (key.Contains("SecurityGroup", StringComparison.OrdinalIgnoreCase))
{
if (value.Contains("0.0.0.0/0") || value.Contains("::/0"))
return true;
}
// Internet-facing load balancer
if (key.Contains("LoadBalancer", StringComparison.OrdinalIgnoreCase) &&
key.Contains("internal", StringComparison.OrdinalIgnoreCase))
{
if (value.Equals("false", StringComparison.OrdinalIgnoreCase))
return true;
}
// Public tags
if (key.Contains("tags", StringComparison.OrdinalIgnoreCase) &&
key.Contains("public", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool DetectHelmPublicExposure(IReadOnlyDictionary<string, string> annotations)
{
foreach (var (key, value) in annotations)
{
// Ingress enabled
if (key.Contains("ingress.enabled", StringComparison.OrdinalIgnoreCase))
{
if (value.Equals("true", StringComparison.OrdinalIgnoreCase))
return true;
}
// LoadBalancer service
if (key.Contains("service.type", StringComparison.OrdinalIgnoreCase))
{
if (value.Equals("LoadBalancer", StringComparison.OrdinalIgnoreCase))
return true;
}
// NodePort service
if (key.Contains("service.type", StringComparison.OrdinalIgnoreCase))
{
if (value.Equals("NodePort", StringComparison.OrdinalIgnoreCase))
return true;
}
}
return false;
}
private static bool DetectGenericPublicExposure(IReadOnlyDictionary<string, string> annotations)
{
foreach (var (key, value) in annotations)
{
// Generic public indicators
if (key.Contains("public", StringComparison.OrdinalIgnoreCase) ||
key.Contains("internet", StringComparison.OrdinalIgnoreCase) ||
key.Contains("external", StringComparison.OrdinalIgnoreCase))
{
if (value.Equals("true", StringComparison.OrdinalIgnoreCase))
return true;
}
// CIDR 0.0.0.0/0
if (value.Contains("0.0.0.0/0"))
return true;
}
return false;
}
private static BoundarySurface DetermineSurface(
BoundaryExtractionContext context,
IReadOnlyDictionary<string, string> annotations,
string iacType)
{
string? path = null;
string protocol = "https";
int? port = null;
string? host = null;
// IaC-specific path/host extraction
path = iacType switch
{
"terraform" => TryGetAnnotation(annotations, "terraform.resource.path", "path"),
"cloudformation" => TryGetAnnotation(annotations, "cloudformation.path", "path"),
"pulumi" => TryGetAnnotation(annotations, "pulumi.path", "path"),
"helm" => TryGetAnnotation(annotations, "helm.values.ingress.path", "ingress.path"),
_ => TryGetAnnotation(annotations, "path")
};
// Default path
path ??= !string.IsNullOrEmpty(context.Namespace) ? $"/{context.Namespace}" : "/";
// Host extraction
host = iacType switch
{
"terraform" => TryGetAnnotation(annotations, "terraform.resource.domain", "domain"),
"cloudformation" => TryGetAnnotation(annotations, "cloudformation.domain", "domain"),
"pulumi" => TryGetAnnotation(annotations, "pulumi.domain", "domain"),
"helm" => TryGetAnnotation(annotations, "helm.values.ingress.host", "ingress.host"),
_ => TryGetAnnotation(annotations, "domain", "host")
};
// Port extraction
var portStr = TryGetAnnotation(annotations, "port", "listener.port", "service.port");
if (portStr != null && int.TryParse(portStr, out var parsedPort))
{
port = parsedPort;
}
else if (context.PortBindings.Count > 0)
{
port = context.PortBindings.Keys.FirstOrDefault();
}
// Determine protocol from annotations
if (annotations.Keys.Any(k => k.Contains("grpc", StringComparison.OrdinalIgnoreCase)))
{
protocol = "grpc";
}
else if (annotations.Keys.Any(k =>
k.Contains("tcp", StringComparison.OrdinalIgnoreCase) &&
!k.Contains("https", StringComparison.OrdinalIgnoreCase)))
{
protocol = "tcp";
}
return new BoundarySurface
{
Type = "infrastructure",
Protocol = protocol,
Port = port,
Host = host,
Path = path
};
}
private static BoundaryAuth? DetectAuth(
IReadOnlyDictionary<string, string> annotations,
string iacType)
{
string? authType = null;
var required = false;
List<string>? roles = null;
string? provider = null;
switch (iacType)
{
case "terraform":
case "cloudformation":
case "pulumi":
(authType, required, provider) = DetectCloudAuth(annotations);
break;
case "helm":
(authType, required, provider) = DetectHelmAuth(annotations);
break;
default:
(authType, required, provider) = DetectGenericAuth(annotations);
break;
}
if (!required)
{
return null;
}
return new BoundaryAuth
{
Required = required,
Type = authType,
Roles = roles,
Provider = provider,
MfaRequired = null
};
}
private static (string? authType, bool required, string? provider) DetectCloudAuth(
IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
string? provider = null;
foreach (var (key, value) in annotations)
{
// IAM authentication
if (key.Contains("iam", StringComparison.OrdinalIgnoreCase) &&
(key.Contains("auth", StringComparison.OrdinalIgnoreCase) ||
key.Contains("policy", StringComparison.OrdinalIgnoreCase)))
{
authType = "iam";
required = true;
provider = "aws-iam";
}
// Cognito authentication
if (key.Contains("cognito", StringComparison.OrdinalIgnoreCase))
{
authType = "oauth2";
required = true;
provider = "cognito";
}
// Azure AD authentication
if (key.Contains("azure_ad", StringComparison.OrdinalIgnoreCase) ||
key.Contains("aad", StringComparison.OrdinalIgnoreCase))
{
authType = "oauth2";
required = true;
provider = "azure-ad";
}
// GCP IAM
if (key.Contains("gcp", StringComparison.OrdinalIgnoreCase) &&
key.Contains("iam", StringComparison.OrdinalIgnoreCase))
{
authType = "iam";
required = true;
provider = "gcp-iam";
}
// mTLS
if (key.Contains("mtls", StringComparison.OrdinalIgnoreCase) ||
key.Contains("client_certificate", StringComparison.OrdinalIgnoreCase))
{
authType = "mtls";
required = true;
}
}
return (authType, required, provider);
}
private static (string? authType, bool required, string? provider) DetectHelmAuth(
IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
string? provider = null;
foreach (var (key, value) in annotations)
{
// OAuth2 proxy
if (key.Contains("oauth2-proxy", StringComparison.OrdinalIgnoreCase))
{
authType = "oauth2";
required = true;
}
// Basic auth
if (key.Contains("auth.enabled", StringComparison.OrdinalIgnoreCase) &&
value.Equals("true", StringComparison.OrdinalIgnoreCase))
{
authType ??= "basic";
required = true;
}
// TLS/mTLS
if (key.Contains("tls.enabled", StringComparison.OrdinalIgnoreCase) &&
value.Equals("true", StringComparison.OrdinalIgnoreCase))
{
if (key.Contains("mtls", StringComparison.OrdinalIgnoreCase))
{
authType = "mtls";
required = true;
}
}
}
return (authType, required, provider);
}
private static (string? authType, bool required, string? provider) DetectGenericAuth(
IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
string? provider = null;
foreach (var (key, _) in annotations)
{
if (key.Contains("auth", StringComparison.OrdinalIgnoreCase))
{
authType = "custom";
required = true;
break;
}
}
return (authType, required, provider);
}
private List<BoundaryControl> DetectControls(
IReadOnlyDictionary<string, string> annotations,
string iacType)
{
var controls = new List<BoundaryControl>();
var now = _timeProvider.GetUtcNow();
// Security Groups / Firewall Rules
var hasSecurityGroup = annotations.Keys.Any(k =>
k.Contains("security_group", StringComparison.OrdinalIgnoreCase) ||
k.Contains("SecurityGroup", StringComparison.OrdinalIgnoreCase) ||
k.Contains("firewall", StringComparison.OrdinalIgnoreCase) ||
k.Contains("nsg", StringComparison.OrdinalIgnoreCase)); // Azure NSG
if (hasSecurityGroup)
{
controls.Add(new BoundaryControl
{
Type = "security_group",
Active = true,
Config = iacType,
Effectiveness = "high",
VerifiedAt = now
});
}
// WAF
var hasWaf = annotations.Keys.Any(k =>
k.Contains("waf", StringComparison.OrdinalIgnoreCase) ||
k.Contains("WebACL", StringComparison.OrdinalIgnoreCase) ||
k.Contains("ApplicationGateway", StringComparison.OrdinalIgnoreCase));
if (hasWaf)
{
controls.Add(new BoundaryControl
{
Type = "waf",
Active = true,
Config = iacType,
Effectiveness = "high",
VerifiedAt = now
});
}
// VPC / Network isolation
var hasVpc = annotations.Keys.Any(k =>
k.Contains("vpc", StringComparison.OrdinalIgnoreCase) ||
k.Contains("vnet", StringComparison.OrdinalIgnoreCase) ||
k.Contains("subnet", StringComparison.OrdinalIgnoreCase));
if (hasVpc)
{
controls.Add(new BoundaryControl
{
Type = "network_isolation",
Active = true,
Config = iacType,
Effectiveness = "medium",
VerifiedAt = now
});
}
// NACL / Network ACL
var hasNacl = annotations.Keys.Any(k =>
k.Contains("nacl", StringComparison.OrdinalIgnoreCase) ||
k.Contains("network_acl", StringComparison.OrdinalIgnoreCase) ||
k.Contains("NetworkAcl", StringComparison.OrdinalIgnoreCase));
if (hasNacl)
{
controls.Add(new BoundaryControl
{
Type = "network_acl",
Active = true,
Config = iacType,
Effectiveness = "high",
VerifiedAt = now
});
}
// DDoS Protection
var hasDdos = annotations.Keys.Any(k =>
k.Contains("ddos", StringComparison.OrdinalIgnoreCase) ||
k.Contains("shield", StringComparison.OrdinalIgnoreCase));
if (hasDdos)
{
controls.Add(new BoundaryControl
{
Type = "ddos_protection",
Active = true,
Config = iacType,
Effectiveness = "high",
VerifiedAt = now
});
}
// Encryption in transit
var hasEncryption = annotations.Keys.Any(k =>
k.Contains("ssl", StringComparison.OrdinalIgnoreCase) ||
k.Contains("tls", StringComparison.OrdinalIgnoreCase) ||
k.Contains("https_only", StringComparison.OrdinalIgnoreCase));
if (hasEncryption)
{
controls.Add(new BoundaryControl
{
Type = "encryption_in_transit",
Active = true,
Config = iacType,
Effectiveness = "high",
VerifiedAt = now
});
}
// Private endpoints
var hasPrivateEndpoint = annotations.Keys.Any(k =>
k.Contains("private_endpoint", StringComparison.OrdinalIgnoreCase) ||
k.Contains("PrivateLink", StringComparison.OrdinalIgnoreCase) ||
k.Contains("vpc_endpoint", StringComparison.OrdinalIgnoreCase));
if (hasPrivateEndpoint)
{
controls.Add(new BoundaryControl
{
Type = "private_endpoint",
Active = true,
Config = iacType,
Effectiveness = "high",
VerifiedAt = now
});
}
return controls;
}
private static double CalculateConfidence(
IReadOnlyDictionary<string, string> annotations,
string iacType)
{
// Base confidence - IaC is declarative intent, lower than runtime
var confidence = 0.6;
// Higher confidence for known IaC tools
if (iacType != "generic")
{
confidence += 0.1;
}
// Higher confidence if we have security-related resources
if (annotations.Keys.Any(k =>
k.Contains("security", StringComparison.OrdinalIgnoreCase) ||
k.Contains("firewall", StringComparison.OrdinalIgnoreCase) ||
k.Contains("waf", StringComparison.OrdinalIgnoreCase)))
{
confidence += 0.1;
}
// Higher confidence if we have network configuration
if (annotations.Keys.Any(k =>
k.Contains("vpc", StringComparison.OrdinalIgnoreCase) ||
k.Contains("subnet", StringComparison.OrdinalIgnoreCase) ||
k.Contains("network", StringComparison.OrdinalIgnoreCase)))
{
confidence += 0.05;
}
// Cap at 0.85 - IaC is not runtime state
return Math.Min(confidence, 0.85);
}
private static string? TryGetAnnotation(
IReadOnlyDictionary<string, string> annotations,
params string[] keys)
{
foreach (var key in keys)
{
if (annotations.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value))
{
return value;
}
// Also try case-insensitive match
var match = annotations.FirstOrDefault(kv =>
kv.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrEmpty(match.Value))
{
return match.Value;
}
}
return null;
}
private static string BuildEvidenceRef(
BoundaryExtractionContext context,
string rootId,
string iacType)
{
var parts = new List<string> { "iac", iacType };
if (!string.IsNullOrEmpty(context.Namespace))
{
parts.Add(context.Namespace);
}
if (!string.IsNullOrEmpty(context.EnvironmentId))
{
parts.Add(context.EnvironmentId);
}
parts.Add(rootId);
return string.Join("/", parts);
}
}

View File

@@ -0,0 +1,462 @@
// -----------------------------------------------------------------------------
// K8sBoundaryExtractor.cs
// Sprint: SPRINT_3800_0002_0002_boundary_k8s
// Description: Extracts boundary proof from Kubernetes metadata.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.SmartDiff.Detection;
namespace StellaOps.Scanner.Reachability.Boundary;
/// <summary>
/// Extracts boundary proof from Kubernetes deployment metadata.
/// Parses Ingress, Service, and NetworkPolicy resources to determine exposure.
/// </summary>
public sealed class K8sBoundaryExtractor : IBoundaryProofExtractor
{
private readonly ILogger<K8sBoundaryExtractor> _logger;
private readonly TimeProvider _timeProvider;
// Well-known annotations for TLS
private static readonly string[] TlsAnnotations =
[
"nginx.ingress.kubernetes.io/ssl-redirect",
"nginx.ingress.kubernetes.io/force-ssl-redirect",
"cert-manager.io/cluster-issuer",
"cert-manager.io/issuer",
"kubernetes.io/tls-acme"
];
public K8sBoundaryExtractor(
ILogger<K8sBoundaryExtractor> logger,
TimeProvider? timeProvider = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public int Priority => 200; // Higher than RichGraphBoundaryExtractor (100)
/// <inheritdoc />
public bool CanHandle(BoundaryExtractionContext context)
{
// Handle when source is K8s or when we have K8s-specific annotations
if (string.Equals(context.Source, "k8s", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.Source, "kubernetes", StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Also handle if annotations contain K8s-specific keys
return context.Annotations.Keys.Any(k =>
k.Contains("kubernetes.io", StringComparison.OrdinalIgnoreCase) ||
k.Contains("ingress", StringComparison.OrdinalIgnoreCase) ||
k.Contains("k8s", StringComparison.OrdinalIgnoreCase));
}
/// <inheritdoc />
public Task<BoundaryProof?> ExtractAsync(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Extract(root, rootNode, context));
}
/// <inheritdoc />
public BoundaryProof? Extract(
RichGraphRoot root,
RichGraphNode? rootNode,
BoundaryExtractionContext context)
{
ArgumentNullException.ThrowIfNull(root);
if (!CanHandle(context))
{
return null;
}
try
{
var annotations = context.Annotations;
var exposure = DetermineExposure(context);
var surface = DetermineSurface(context, annotations, exposure);
var auth = DetectAuth(annotations);
var controls = DetectControls(annotations, context);
var confidence = CalculateConfidence(exposure, annotations);
_logger.LogDebug(
"K8s boundary extraction: exposure={ExposureLevel}, surface={SurfaceType}, confidence={Confidence:F2}",
exposure.Level,
surface.Type,
confidence);
return new BoundaryProof
{
Kind = DetermineKind(exposure),
Surface = surface,
Exposure = exposure,
Auth = auth,
Controls = controls.Count > 0 ? controls : null,
LastSeen = _timeProvider.GetUtcNow(),
Confidence = confidence,
Source = "k8s",
EvidenceRef = BuildEvidenceRef(context, root.Id)
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "K8s boundary extraction failed for root {RootId}", root.Id);
return null;
}
}
private BoundaryExposure DetermineExposure(BoundaryExtractionContext context)
{
var annotations = context.Annotations;
var level = "private";
var internetFacing = false;
var behindProxy = false;
List<string>? clientTypes = null;
// Check explicit internet-facing flag
if (context.IsInternetFacing == true)
{
level = "public";
internetFacing = true;
clientTypes = ["browser", "api_client"];
}
// Ingress class indicates external exposure
else if (annotations.ContainsKey("kubernetes.io/ingress.class") ||
annotations.Keys.Any(k => k.Contains("ingress", StringComparison.OrdinalIgnoreCase)))
{
level = "public";
internetFacing = true;
behindProxy = true; // ingress controller acts as proxy
clientTypes = ["browser", "api_client"];
}
// Check for LoadBalancer service type
else if (annotations.TryGetValue("service.type", out var serviceType))
{
(level, internetFacing, clientTypes) = serviceType.ToLowerInvariant() switch
{
"loadbalancer" => ("public", true, new List<string> { "api_client", "service" }),
"nodeport" => ("internal", false, new List<string> { "service" }),
"clusterip" => ("private", false, new List<string> { "service" }),
_ => ("private", false, new List<string> { "service" })
};
}
// Check port bindings for common external ports
else if (context.PortBindings.Count > 0)
{
var externalPorts = new HashSet<int> { 80, 443, 8080, 8443 };
if (context.PortBindings.Keys.Any(p => externalPorts.Contains(p)))
{
level = "internal";
clientTypes = ["service"];
}
}
// Default based on network zone
else
{
level = context.NetworkZone switch
{
"dmz" => "internal",
"trusted" or "internal" => "private",
_ => "private"
};
clientTypes = ["service"];
}
return new BoundaryExposure
{
Level = level,
InternetFacing = internetFacing,
Zone = context.NetworkZone,
BehindProxy = behindProxy,
ClientTypes = clientTypes
};
}
private static BoundarySurface DetermineSurface(
BoundaryExtractionContext context,
IReadOnlyDictionary<string, string> annotations,
BoundaryExposure exposure)
{
string? path = null;
string protocol = "https";
int? port = null;
string? host = null;
// Try to extract path from annotations
if (annotations.TryGetValue("service.path", out var servicePath))
{
path = servicePath;
}
else if (annotations.TryGetValue("nginx.ingress.kubernetes.io/rewrite-target", out var rewrite))
{
path = rewrite;
}
else if (!string.IsNullOrEmpty(context.Namespace))
{
path = $"/{context.Namespace}";
}
// Determine protocol
var hasTls = TlsAnnotations.Any(ta =>
annotations.ContainsKey(ta) ||
annotations.Keys.Any(k => k.Contains("tls", StringComparison.OrdinalIgnoreCase)));
protocol = hasTls || exposure.InternetFacing ? "https" : "http";
// Check for grpc
if (annotations.Keys.Any(k => k.Contains("grpc", StringComparison.OrdinalIgnoreCase)))
{
protocol = "grpc";
}
// Get port from bindings
if (context.PortBindings.Count > 0)
{
port = context.PortBindings.Keys.FirstOrDefault();
}
// Get host from annotations
if (annotations.TryGetValue("ingress.host", out var ingressHost))
{
host = ingressHost;
}
return new BoundarySurface
{
Type = exposure.InternetFacing ? "api" : "service",
Protocol = protocol,
Port = port,
Host = host,
Path = path
};
}
private BoundaryAuth? DetectAuth(IReadOnlyDictionary<string, string> annotations)
{
string? authType = null;
var required = false;
List<string>? roles = null;
string? provider = null;
bool? mfaRequired = null;
// Check for auth annotations
foreach (var (key, value) in annotations)
{
// Check auth type annotations
if (key.Contains("auth-type", StringComparison.OrdinalIgnoreCase))
{
authType = value.ToLowerInvariant();
required = true;
}
// Check for basic auth
if (key.Contains("auth-secret", StringComparison.OrdinalIgnoreCase) ||
key.Contains("basic-auth", StringComparison.OrdinalIgnoreCase))
{
authType ??= "basic";
required = true;
}
// Check for OAuth/OIDC
if (key.Contains("oauth", StringComparison.OrdinalIgnoreCase) ||
key.Contains("oidc", StringComparison.OrdinalIgnoreCase))
{
authType = "oauth2";
required = true;
}
// Check for client cert auth
if (key.Contains("client-certificate", StringComparison.OrdinalIgnoreCase) ||
key.Contains("auth-tls", StringComparison.OrdinalIgnoreCase))
{
authType = "mtls";
required = true;
}
// Check for API key auth
if (key.Contains("api-key", StringComparison.OrdinalIgnoreCase))
{
authType = "api_key";
required = true;
}
// Check for auth provider
if (key.Contains("auth-url", StringComparison.OrdinalIgnoreCase))
{
provider = value;
}
// Check for role requirements
if (key.Contains("auth-roles", StringComparison.OrdinalIgnoreCase))
{
roles = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
// Check for MFA requirement
if (key.Contains("mfa", StringComparison.OrdinalIgnoreCase))
{
mfaRequired = bool.TryParse(value, out var mfa) ? mfa : true;
}
}
if (!required)
{
return null;
}
return new BoundaryAuth
{
Required = required,
Type = authType,
Roles = roles,
Provider = provider,
MfaRequired = mfaRequired
};
}
private List<BoundaryControl> DetectControls(
IReadOnlyDictionary<string, string> annotations,
BoundaryExtractionContext context)
{
var controls = new List<BoundaryControl>();
var now = _timeProvider.GetUtcNow();
// Check for NetworkPolicy
if (annotations.ContainsKey("network.policy.enabled") ||
annotations.Keys.Any(k => k.Contains("networkpolicy", StringComparison.OrdinalIgnoreCase)))
{
controls.Add(new BoundaryControl
{
Type = "network_policy",
Active = true,
Config = context.Namespace ?? "default",
Effectiveness = "high",
VerifiedAt = now
});
}
// Check for rate limiting
if (annotations.Keys.Any(k =>
k.Contains("rate-limit", StringComparison.OrdinalIgnoreCase) ||
k.Contains("ratelimit", StringComparison.OrdinalIgnoreCase)))
{
var rateValue = annotations.FirstOrDefault(kv =>
kv.Key.Contains("rate", StringComparison.OrdinalIgnoreCase)).Value ?? "default";
controls.Add(new BoundaryControl
{
Type = "rate_limit",
Active = true,
Config = rateValue,
Effectiveness = "medium",
VerifiedAt = now
});
}
// Check for IP whitelist
if (annotations.Keys.Any(k =>
k.Contains("whitelist", StringComparison.OrdinalIgnoreCase) ||
k.Contains("allowlist", StringComparison.OrdinalIgnoreCase)))
{
controls.Add(new BoundaryControl
{
Type = "ip_allowlist",
Active = true,
Config = "ingress",
Effectiveness = "high",
VerifiedAt = now
});
}
// Check for WAF
if (annotations.Keys.Any(k =>
k.Contains("waf", StringComparison.OrdinalIgnoreCase) ||
k.Contains("modsecurity", StringComparison.OrdinalIgnoreCase)))
{
controls.Add(new BoundaryControl
{
Type = "waf",
Active = true,
Config = "ingress",
Effectiveness = "high",
VerifiedAt = now
});
}
// Check for input validation
if (annotations.Keys.Any(k =>
k.Contains("validation", StringComparison.OrdinalIgnoreCase)))
{
controls.Add(new BoundaryControl
{
Type = "input_validation",
Active = true,
Effectiveness = "medium",
VerifiedAt = now
});
}
return controls;
}
private static string DetermineKind(BoundaryExposure exposure)
{
return exposure.InternetFacing ? "network" : "network";
}
private static double CalculateConfidence(
BoundaryExposure exposure,
IReadOnlyDictionary<string, string> annotations)
{
// Base confidence from K8s source
var confidence = 0.7;
// Higher confidence if we have explicit ingress annotations
if (annotations.Keys.Any(k => k.Contains("ingress", StringComparison.OrdinalIgnoreCase)))
{
confidence += 0.15;
}
// Higher confidence if we have service type
if (annotations.ContainsKey("service.type"))
{
confidence += 0.1;
}
// Cap at 0.95 - K8s extraction is high confidence but not runtime-verified
return Math.Min(confidence, 0.95);
}
private static string BuildEvidenceRef(BoundaryExtractionContext context, string rootId)
{
var parts = new List<string> { "k8s" };
if (!string.IsNullOrEmpty(context.Namespace))
{
parts.Add(context.Namespace);
}
if (!string.IsNullOrEmpty(context.EnvironmentId))
{
parts.Add(context.EnvironmentId);
}
parts.Add(rootId);
return string.Join("/", parts);
}
}

View File

@@ -0,0 +1,212 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Collectors;
/// <summary>
/// Collector for external call surface entries.
/// Detects outbound HTTP requests, API calls, and external service integrations.
/// </summary>
public sealed class ExternalCallCollector : PatternBasedSurfaceCollector
{
private static readonly IReadOnlyList<SurfacePattern> s_patterns =
[
// .NET HttpClient
new SurfacePattern
{
Id = "dotnet-httpclient",
Pattern = new Regex(@"(?:HttpClient|IHttpClientFactory).*\.(Get|Post|Put|Delete|Send)Async\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "dotnet"],
FileExtensions = new HashSet<string> { ".cs" }
},
new SurfacePattern
{
Id = "dotnet-new-httpclient",
Pattern = new Regex(@"new\s+HttpClient\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.Medium,
Tags = ["http", "external", "dotnet"],
FileExtensions = new HashSet<string> { ".cs" }
},
// Node.js fetch/axios/request
new SurfacePattern
{
Id = "node-fetch",
Pattern = new Regex(@"(?:fetch|axios|got|request|node-fetch)\s*\(\s*[""'`]https?://", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["http", "external", "nodejs"],
FileExtensions = new HashSet<string> { ".js", ".ts", ".mjs" }
},
new SurfacePattern
{
Id = "node-axios-method",
Pattern = new Regex(@"axios\.(get|post|put|delete|patch|request)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "nodejs", "axios"],
FileExtensions = new HashSet<string> { ".js", ".ts", ".mjs" }
},
// Python requests/urllib/httpx
new SurfacePattern
{
Id = "python-requests",
Pattern = new Regex(@"requests\.(get|post|put|delete|patch|head|options)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["http", "external", "python", "requests"],
FileExtensions = new HashSet<string> { ".py" }
},
new SurfacePattern
{
Id = "python-urllib",
Pattern = new Regex(@"urllib\.request\.urlopen\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "python", "urllib"],
FileExtensions = new HashSet<string> { ".py" }
},
new SurfacePattern
{
Id = "python-httpx",
Pattern = new Regex(@"(?:httpx|aiohttp)\.(get|post|put|delete|patch|request)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["http", "external", "python"],
FileExtensions = new HashSet<string> { ".py" }
},
// Go http client
new SurfacePattern
{
Id = "go-http-get",
Pattern = new Regex(@"http\.(Get|Post|PostForm|Head)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["http", "external", "go"],
FileExtensions = new HashSet<string> { ".go" }
},
new SurfacePattern
{
Id = "go-http-do",
Pattern = new Regex(@"(?:client|http\.Client)\.Do\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "go"],
FileExtensions = new HashSet<string> { ".go" }
},
// Java HTTP clients
new SurfacePattern
{
Id = "java-httpclient",
Pattern = new Regex(@"HttpClient\.send\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "java"],
FileExtensions = new HashSet<string> { ".java", ".kt" }
},
new SurfacePattern
{
Id = "java-okhttp",
Pattern = new Regex(@"(?:OkHttpClient|RestTemplate|WebClient).*\.(execute|exchange|retrieve|newCall)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "java"],
FileExtensions = new HashSet<string> { ".java", ".kt" }
},
// Ruby HTTP clients
new SurfacePattern
{
Id = "ruby-http",
Pattern = new Regex(@"(?:Net::HTTP|HTTParty|Faraday|RestClient)\.(get|post|put|delete|patch)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "ruby"],
FileExtensions = new HashSet<string> { ".rb" }
},
// PHP HTTP clients
new SurfacePattern
{
Id = "php-curl",
Pattern = new Regex(@"curl_(?:exec|init|setopt)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "php", "curl"],
FileExtensions = new HashSet<string> { ".php" }
},
new SurfacePattern
{
Id = "php-guzzle",
Pattern = new Regex(@"(?:GuzzleHttp|Client).*->(get|post|put|delete|patch|request)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["http", "external", "php", "guzzle"],
FileExtensions = new HashSet<string> { ".php" }
},
// gRPC client calls
new SurfacePattern
{
Id = "grpc-client",
Pattern = new Regex(@"(?:grpc\.dial|NewClient|\.Invoke)\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["grpc", "external", "rpc"]
},
// GraphQL clients
new SurfacePattern
{
Id = "graphql-client",
Pattern = new Regex(@"(?:graphql|apollo).*\.(query|mutate|subscribe)\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["graphql", "external", "api"]
},
// WebSocket client connections
new SurfacePattern
{
Id = "websocket-client",
Pattern = new Regex(@"new\s+WebSocket\s*\(\s*[""']wss?://", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.High,
Tags = ["websocket", "external"]
},
// SMTP/Email
new SurfacePattern
{
Id = "smtp-send",
Pattern = new Regex(@"(?:SmtpClient|sendmail|nodemailer|mail).*\.send\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.ExternalCall,
Confidence = ConfidenceLevel.Medium,
Tags = ["smtp", "email", "external"]
}
];
public ExternalCallCollector(ILogger<ExternalCallCollector> logger) : base(logger)
{
}
/// <inheritdoc />
public override string CollectorId => "surface.external-call";
/// <inheritdoc />
public override string DisplayName => "External Call Collector";
/// <inheritdoc />
public override IReadOnlySet<SurfaceType> SupportedTypes { get; } =
new HashSet<SurfaceType> { SurfaceType.ExternalCall };
/// <inheritdoc />
protected override IReadOnlyList<SurfacePattern> Patterns => s_patterns;
}

View File

@@ -0,0 +1,170 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Collectors;
/// <summary>
/// Collector for network endpoint surface entries.
/// Detects exposed ports, listeners, and network-facing code.
/// </summary>
public sealed class NetworkEndpointCollector : PatternBasedSurfaceCollector
{
private static readonly IReadOnlyList<SurfacePattern> s_patterns =
[
// TCP/UDP listeners
new SurfacePattern
{
Id = "net-listen-port",
Pattern = new Regex(@"\.Listen\s*\(\s*(\d+|""[^""]+""|'[^']+')", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.High,
Tags = ["network", "listener", "port"]
},
new SurfacePattern
{
Id = "net-bind-address",
Pattern = new Regex(@"\.Bind\s*\(\s*[""']?(0\.0\.0\.0|::|localhost|\d+\.\d+\.\d+\.\d+)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.High,
Tags = ["network", "bind", "address"]
},
// Express.js / Node.js
new SurfacePattern
{
Id = "express-listen",
Pattern = new Regex(@"app\.listen\s*\(\s*(\d+|process\.env\.\w+)", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["network", "express", "nodejs"],
FileExtensions = new HashSet<string> { ".js", ".ts", ".mjs" }
},
new SurfacePattern
{
Id = "express-route",
Pattern = new Regex(@"(app|router)\.(get|post|put|delete|patch|all)\s*\(\s*[""'/]", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.High,
Tags = ["network", "express", "route", "http"],
FileExtensions = new HashSet<string> { ".js", ".ts", ".mjs" }
},
// ASP.NET Core
new SurfacePattern
{
Id = "aspnet-controller",
Pattern = new Regex(@"\[(?:Http(?:Get|Post|Put|Delete|Patch)|Route)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["network", "aspnet", "controller", "http"],
FileExtensions = new HashSet<string> { ".cs" }
},
new SurfacePattern
{
Id = "aspnet-minimal-api",
Pattern = new Regex(@"app\.Map(Get|Post|Put|Delete|Patch)\s*\(\s*""", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["network", "aspnet", "minimal-api", "http"],
FileExtensions = new HashSet<string> { ".cs" }
},
new SurfacePattern
{
Id = "kestrel-listen",
Pattern = new Regex(@"\.UseUrls?\s*\(\s*""https?://", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.High,
Tags = ["network", "kestrel", "aspnet"],
FileExtensions = new HashSet<string> { ".cs" }
},
// Python Flask/FastAPI
new SurfacePattern
{
Id = "flask-route",
Pattern = new Regex(@"@app\.route\s*\(\s*[""'/]", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["network", "flask", "python", "http"],
FileExtensions = new HashSet<string> { ".py" }
},
new SurfacePattern
{
Id = "fastapi-route",
Pattern = new Regex(@"@(?:app|router)\.(get|post|put|delete|patch)\s*\(\s*""", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["network", "fastapi", "python", "http"],
FileExtensions = new HashSet<string> { ".py" }
},
// Go
new SurfacePattern
{
Id = "go-http-handle",
Pattern = new Regex(@"http\.Handle(?:Func)?\s*\(\s*""", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["network", "go", "http"],
FileExtensions = new HashSet<string> { ".go" }
},
new SurfacePattern
{
Id = "go-listen-serve",
Pattern = new Regex(@"http\.ListenAndServe\s*\(\s*""[^""]*:\d+", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["network", "go", "http", "listener"],
FileExtensions = new HashSet<string> { ".go" }
},
// Java Spring
new SurfacePattern
{
Id = "spring-mapping",
Pattern = new Regex(@"@(?:Request|Get|Post|Put|Delete|Patch)Mapping\s*\(", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["network", "spring", "java", "http"],
FileExtensions = new HashSet<string> { ".java", ".kt" }
},
// WebSocket
new SurfacePattern
{
Id = "websocket-server",
Pattern = new Regex(@"new\s+WebSocket(?:Server)?\s*\(", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.High,
Tags = ["network", "websocket"]
},
// gRPC
new SurfacePattern
{
Id = "grpc-server",
Pattern = new Regex(@"(?:grpc\.)?(?:NewServer|Server)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.NetworkEndpoint,
Confidence = ConfidenceLevel.High,
Tags = ["network", "grpc"]
}
];
public NetworkEndpointCollector(ILogger<NetworkEndpointCollector> logger) : base(logger)
{
}
/// <inheritdoc />
public override string CollectorId => "surface.network-endpoint";
/// <inheritdoc />
public override string DisplayName => "Network Endpoint Collector";
/// <inheritdoc />
public override IReadOnlySet<SurfaceType> SupportedTypes { get; } =
new HashSet<SurfaceType> { SurfaceType.NetworkEndpoint };
/// <inheritdoc />
protected override IReadOnlyList<SurfacePattern> Patterns => s_patterns;
}

View File

@@ -0,0 +1,278 @@
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Discovery;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Collectors;
/// <summary>
/// Entry point collector for JavaScript/TypeScript applications.
/// Detects Express, Fastify, Koa, Hapi, and NestJS routes.
/// </summary>
public sealed class NodeJsEntryPointCollector : IEntryPointCollector
{
private readonly ILogger<NodeJsEntryPointCollector> _logger;
// Patterns for detecting routes
private static readonly Regex s_expressRoute = new(
@"(?:app|router)\s*\.\s*(get|post|put|delete|patch|all|options|head)\s*\(\s*[""'`]([^""'`]+)[""'`]\s*,",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex s_fastifyRoute = new(
@"(?:fastify|app|server)\s*\.\s*(get|post|put|delete|patch|all|options|head)\s*\(\s*[""'`]([^""'`]+)[""'`]\s*,",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex s_koaRoute = new(
@"router\s*\.\s*(get|post|put|delete|patch|all)\s*\(\s*[""'`]([^""'`]+)[""'`]",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex s_nestController = new(
@"@Controller\s*\(\s*[""'`]?([^""'`\)]*)[""'`]?\s*\)",
RegexOptions.Compiled);
private static readonly Regex s_nestMethod = new(
@"@(Get|Post|Put|Delete|Patch|All|Options|Head)\s*\(\s*[""'`]?([^""'`\)]*)[""'`]?\s*\)",
RegexOptions.Compiled);
private static readonly Regex s_handlerFunction = new(
@"(?:async\s+)?(?:function\s+)?(\w+)\s*\(|(\w+)\s*:\s*(?:RequestHandler|RouteHandler)|(\w+)\s*=\s*(?:async\s+)?\(",
RegexOptions.Compiled);
public NodeJsEntryPointCollector(ILogger<NodeJsEntryPointCollector> logger)
{
_logger = logger;
}
/// <inheritdoc />
public string CollectorId => "entrypoint.nodejs";
/// <inheritdoc />
public IReadOnlySet<string> SupportedLanguages { get; } =
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "javascript", "typescript", "js", "ts" };
/// <inheritdoc />
public async IAsyncEnumerable<EntryPoint> CollectAsync(
SurfaceCollectorContext context,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var extensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".js", ".ts", ".mjs", ".jsx", ".tsx"
};
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(context.RootPath, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 20
}).Where(f => extensions.Contains(Path.GetExtension(f)));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enumerate files in {Path}", context.RootPath);
yield break;
}
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
var relativePath = Path.GetRelativePath(context.RootPath, file);
string[] lines;
try
{
lines = await File.ReadAllLinesAsync(file, cancellationToken);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read file {File}", file);
continue;
}
string? controllerPath = null;
var framework = DetectFramework(lines);
for (var i = 0; i < lines.Length; i++)
{
var line = lines[i];
// Check for NestJS controller decorator
var controllerMatch = s_nestController.Match(line);
if (controllerMatch.Success)
{
controllerPath = controllerMatch.Groups[1].Value;
continue;
}
// Check for route definitions
var entryPoint = TryParseRoute(line, i, lines, relativePath, framework, controllerPath);
if (entryPoint != null)
{
yield return entryPoint;
}
}
}
}
private EntryPoint? TryParseRoute(
string line,
int lineIndex,
string[] lines,
string file,
string framework,
string? controllerPath)
{
Match? match = null;
string? method = null;
string? path = null;
// Try Express/Fastify pattern
match = s_expressRoute.Match(line);
if (!match.Success)
match = s_fastifyRoute.Match(line);
if (!match.Success)
match = s_koaRoute.Match(line);
if (match.Success)
{
method = match.Groups[1].Value.ToUpperInvariant();
path = match.Groups[2].Value;
}
// Try NestJS method decorators
if (!match.Success)
{
match = s_nestMethod.Match(line);
if (match.Success)
{
method = match.Groups[1].Value.ToUpperInvariant();
path = match.Groups[2].Value;
if (!string.IsNullOrEmpty(controllerPath))
{
path = $"/{controllerPath.TrimStart('/')}/{path.TrimStart('/')}".Replace("//", "/");
}
}
}
if (!match.Success)
return null;
// Find handler name
var handler = FindHandlerName(lines, lineIndex);
// Find middleware
var middlewares = FindMiddlewares(lines, lineIndex);
// Find parameters from path
var parameters = ExtractPathParameters(path ?? "");
var id = ComputeEntryPointId(file, method ?? "GET", path ?? "/");
return new EntryPoint
{
Id = id,
Language = "javascript",
Framework = framework,
Path = path ?? "/",
Method = method,
Handler = handler,
File = file,
Line = lineIndex + 1,
Parameters = parameters,
Middlewares = middlewares
};
}
private static string DetectFramework(string[] lines)
{
var content = string.Join("\n", lines.Take(100));
if (content.Contains("@nestjs/") || content.Contains("@Controller"))
return "nestjs";
if (content.Contains("fastify") || content.Contains("Fastify"))
return "fastify";
if (content.Contains("koa") || content.Contains("Koa"))
return "koa";
if (content.Contains("hapi") || content.Contains("@hapi/"))
return "hapi";
if (content.Contains("express") || content.Contains("Express"))
return "express";
return "nodejs";
}
private static string FindHandlerName(string[] lines, int lineIndex)
{
// Look at current and next few lines for handler
for (var i = lineIndex; i < Math.Min(lines.Length, lineIndex + 5); i++)
{
var match = s_handlerFunction.Match(lines[i]);
if (match.Success)
{
for (var g = 1; g <= match.Groups.Count; g++)
{
if (match.Groups[g].Success && !string.IsNullOrEmpty(match.Groups[g].Value))
{
return match.Groups[g].Value;
}
}
}
}
return "anonymous";
}
private static List<string> FindMiddlewares(string[] lines, int lineIndex)
{
var middlewares = new List<string>();
var middlewarePattern = new Regex(@"(?:use|middleware)\s*\(\s*(\w+)", RegexOptions.Compiled);
// Look backwards for middleware
for (var i = lineIndex - 1; i >= Math.Max(0, lineIndex - 10); i--)
{
var match = middlewarePattern.Match(lines[i]);
if (match.Success)
{
middlewares.Add(match.Groups[1].Value);
}
}
// Also check inline middleware in route definition
var inlineMatch = middlewarePattern.Match(lines[lineIndex]);
if (inlineMatch.Success)
{
middlewares.Add(inlineMatch.Groups[1].Value);
}
return middlewares;
}
private static List<string> ExtractPathParameters(string path)
{
var parameters = new List<string>();
var paramPattern = new Regex(@":(\w+)|{(\w+)}", RegexOptions.Compiled);
var matches = paramPattern.Matches(path);
foreach (Match match in matches)
{
var param = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
parameters.Add(param);
}
return parameters;
}
private static string ComputeEntryPointId(string file, string method, string path)
{
var input = $"{file}:{method}:{path}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant()[..16];
}
}

View File

@@ -0,0 +1,279 @@
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Discovery;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Collectors;
/// <summary>
/// Pattern definition for surface detection.
/// </summary>
public sealed record SurfacePattern
{
/// <summary>Pattern identifier.</summary>
public required string Id { get; init; }
/// <summary>Regex pattern to match.</summary>
public required Regex Pattern { get; init; }
/// <summary>Surface type this pattern detects.</summary>
public required SurfaceType Type { get; init; }
/// <summary>Base confidence level for matches.</summary>
public ConfidenceLevel Confidence { get; init; } = ConfidenceLevel.Medium;
/// <summary>Classification tags.</summary>
public IReadOnlyList<string> Tags { get; init; } = [];
/// <summary>File extensions this pattern applies to.</summary>
public IReadOnlySet<string> FileExtensions { get; init; } = new HashSet<string>();
/// <summary>Context pattern to boost confidence when found nearby.</summary>
public Regex? ContextBoostPattern { get; init; }
}
/// <summary>
/// Base class for pattern-based surface entry collectors.
/// </summary>
public abstract class PatternBasedSurfaceCollector : ISurfaceEntryCollector
{
private readonly ILogger _logger;
protected PatternBasedSurfaceCollector(ILogger logger)
{
_logger = logger;
}
/// <inheritdoc />
public abstract string CollectorId { get; }
/// <inheritdoc />
public abstract string DisplayName { get; }
/// <inheritdoc />
public abstract IReadOnlySet<SurfaceType> SupportedTypes { get; }
/// <summary>Gets the patterns used by this collector.</summary>
protected abstract IReadOnlyList<SurfacePattern> Patterns { get; }
/// <inheritdoc />
public async IAsyncEnumerable<SurfaceEntry> CollectAsync(
SurfaceCollectorContext context,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var files = EnumerateSourceFiles(context.RootPath, cancellationToken);
await foreach (var file in files.WithCancellation(cancellationToken))
{
var relativePath = Path.GetRelativePath(context.RootPath, file);
var extension = Path.GetExtension(file).ToLowerInvariant();
string[] lines;
try
{
lines = await File.ReadAllLinesAsync(file, cancellationToken);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read file {File}", file);
continue;
}
for (var lineIndex = 0; lineIndex < lines.Length; lineIndex++)
{
var line = lines[lineIndex];
foreach (var pattern in Patterns)
{
// Skip patterns that don't apply to this file type
if (pattern.FileExtensions.Count > 0 && !pattern.FileExtensions.Contains(extension))
continue;
// Skip patterns for excluded types
if (context.Options.ExcludeTypes.Contains(pattern.Type))
continue;
if (context.Options.IncludeTypes.Count > 0 && !context.Options.IncludeTypes.Contains(pattern.Type))
continue;
var match = pattern.Pattern.Match(line);
if (!match.Success)
continue;
// Determine confidence with context boost
var confidence = pattern.Confidence;
if (pattern.ContextBoostPattern != null)
{
var contextStart = Math.Max(0, lineIndex - 5);
var contextEnd = Math.Min(lines.Length, lineIndex + 5);
for (var i = contextStart; i < contextEnd; i++)
{
if (pattern.ContextBoostPattern.IsMatch(lines[i]))
{
confidence = BoostConfidence(confidence);
break;
}
}
}
// Apply minimum confidence filter
if (GetConfidenceValue(confidence) < context.Options.MinimumConfidence)
continue;
// Determine context (function/class name)
var contextName = FindContext(lines, lineIndex);
// Build snippet
string? snippet = null;
if (context.Options.IncludeSnippets)
{
snippet = BuildSnippet(lines, lineIndex, context.Options.MaxSnippetLength);
}
var id = SurfaceEntry.ComputeId(pattern.Type, relativePath, contextName);
var hash = ComputeEvidenceHash(relativePath, lineIndex + 1, line);
yield return new SurfaceEntry
{
Id = id,
Type = pattern.Type,
Path = relativePath,
Context = contextName,
Confidence = confidence,
Tags = [.. pattern.Tags],
Evidence = new SurfaceEvidence
{
File = relativePath,
Line = lineIndex + 1,
Hash = hash,
Snippet = snippet,
Metadata = new Dictionary<string, string>
{
["pattern_id"] = pattern.Id,
["match"] = match.Value
}
}
};
}
}
}
}
/// <summary>Enumerates source files in the given path.</summary>
protected virtual async IAsyncEnumerable<string> EnumerateSourceFiles(
string rootPath,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var extensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
".cs", ".js", ".ts", ".jsx", ".tsx", ".py", ".java", ".go", ".rb", ".php",
".c", ".cpp", ".h", ".hpp", ".rs", ".swift", ".kt", ".scala", ".sh", ".ps1"
};
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(rootPath, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 20
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enumerate files in {Path}", rootPath);
yield break;
}
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
var ext = Path.GetExtension(file).ToLowerInvariant();
if (extensions.Contains(ext))
{
yield return file;
}
}
await Task.CompletedTask;
}
/// <summary>Finds the enclosing context (function/class) for a line.</summary>
protected virtual string FindContext(string[] lines, int lineIndex)
{
// Look backwards for function/class definition patterns
var patterns = new Regex[]
{
new(@"^\s*(?:public|private|protected|internal|static|async)?\s*(?:class|struct|interface)\s+(\w+)", RegexOptions.Compiled),
new(@"^\s*(?:public|private|protected|internal|static|async)?\s*\w+\s+(\w+)\s*\(", RegexOptions.Compiled),
new(@"^\s*(?:function|async\s+function)\s+(\w+)\s*\(", RegexOptions.Compiled),
new(@"^\s*(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\(?", RegexOptions.Compiled),
new(@"^\s*def\s+(\w+)\s*\(", RegexOptions.Compiled),
new(@"^\s*(?:func)\s+(\w+)\s*\(", RegexOptions.Compiled)
};
for (var i = lineIndex; i >= 0 && i > lineIndex - 50; i--)
{
foreach (var pattern in patterns)
{
var match = pattern.Match(lines[i]);
if (match.Success)
{
return match.Groups[1].Value;
}
}
}
return "anonymous";
}
/// <summary>Builds a code snippet around the given line.</summary>
protected virtual string BuildSnippet(string[] lines, int lineIndex, int maxLength)
{
var start = Math.Max(0, lineIndex - 2);
var end = Math.Min(lines.Length, lineIndex + 3);
var sb = new StringBuilder();
for (var i = start; i < end; i++)
{
if (sb.Length + lines[i].Length > maxLength)
break;
sb.AppendLine(lines[i]);
}
return sb.ToString().TrimEnd();
}
/// <summary>Computes hash for evidence.</summary>
protected static string ComputeEvidenceHash(string file, int line, string content)
{
var input = $"{file}:{line}:{content}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>Boosts confidence level by one step.</summary>
protected static ConfidenceLevel BoostConfidence(ConfidenceLevel current) => current switch
{
ConfidenceLevel.Low => ConfidenceLevel.Medium,
ConfidenceLevel.Medium => ConfidenceLevel.High,
ConfidenceLevel.High => ConfidenceLevel.VeryHigh,
_ => current
};
/// <summary>Gets numeric value for confidence level.</summary>
protected static double GetConfidenceValue(ConfidenceLevel level) => level switch
{
ConfidenceLevel.Low => 0.25,
ConfidenceLevel.Medium => 0.5,
ConfidenceLevel.High => 0.75,
ConfidenceLevel.VeryHigh => 1.0,
_ => 0.5
};
}

View File

@@ -0,0 +1,177 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Collectors;
/// <summary>
/// Collector for process execution surface entries.
/// Detects subprocess spawning, command execution, and shell invocations.
/// </summary>
public sealed class ProcessExecutionCollector : PatternBasedSurfaceCollector
{
private static readonly IReadOnlyList<SurfacePattern> s_patterns =
[
// .NET Process
new SurfacePattern
{
Id = "dotnet-process-start",
Pattern = new Regex(@"Process\.Start\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "dotnet"],
FileExtensions = new HashSet<string> { ".cs" }
},
new SurfacePattern
{
Id = "dotnet-process-info",
Pattern = new Regex(@"new\s+ProcessStartInfo\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.High,
Tags = ["process", "execution", "dotnet"],
FileExtensions = new HashSet<string> { ".cs" }
},
// Node.js child_process
new SurfacePattern
{
Id = "node-exec",
Pattern = new Regex(@"(?:exec|execSync|spawn|spawnSync|fork)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "nodejs"],
FileExtensions = new HashSet<string> { ".js", ".ts", ".mjs" },
ContextBoostPattern = new Regex(@"child_process|require\([""']child_process[""']\)", RegexOptions.Compiled)
},
new SurfacePattern
{
Id = "node-shell-true",
Pattern = new Regex(@"shell\s*:\s*true", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "shell", "nodejs", "critical"],
FileExtensions = new HashSet<string> { ".js", ".ts", ".mjs" }
},
// Python subprocess
new SurfacePattern
{
Id = "python-subprocess",
Pattern = new Regex(@"subprocess\.(run|call|Popen|check_output|check_call)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "python"],
FileExtensions = new HashSet<string> { ".py" }
},
new SurfacePattern
{
Id = "python-os-system",
Pattern = new Regex(@"os\.(system|popen|spawn|exec)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "python", "shell"],
FileExtensions = new HashSet<string> { ".py" }
},
new SurfacePattern
{
Id = "python-shell-true",
Pattern = new Regex(@"shell\s*=\s*True", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "shell", "python", "critical"],
FileExtensions = new HashSet<string> { ".py" }
},
// Go exec
new SurfacePattern
{
Id = "go-exec-command",
Pattern = new Regex(@"exec\.Command\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "go"],
FileExtensions = new HashSet<string> { ".go" }
},
// Java Runtime/ProcessBuilder
new SurfacePattern
{
Id = "java-runtime-exec",
Pattern = new Regex(@"Runtime\.getRuntime\(\)\.exec\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "java"],
FileExtensions = new HashSet<string> { ".java", ".kt" }
},
new SurfacePattern
{
Id = "java-processbuilder",
Pattern = new Regex(@"new\s+ProcessBuilder\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "java"],
FileExtensions = new HashSet<string> { ".java", ".kt" }
},
// Ruby system/exec
new SurfacePattern
{
Id = "ruby-system-exec",
Pattern = new Regex(@"(?:system|exec|spawn|`[^`]+`)\s*[\(\[]?[""']", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.High,
Tags = ["process", "execution", "ruby"],
FileExtensions = new HashSet<string> { ".rb" }
},
// PHP exec family
new SurfacePattern
{
Id = "php-exec",
Pattern = new Regex(@"(?:exec|shell_exec|system|passthru|popen|proc_open)\s*\(", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "php"],
FileExtensions = new HashSet<string> { ".php" }
},
// Shell scripts
new SurfacePattern
{
Id = "bash-eval",
Pattern = new Regex(@"(?:eval|source)\s+[""'\$]", RegexOptions.Compiled),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.High,
Tags = ["process", "execution", "shell", "eval"],
FileExtensions = new HashSet<string> { ".sh", ".bash" }
},
// PowerShell
new SurfacePattern
{
Id = "powershell-invoke",
Pattern = new Regex(@"(?:Invoke-Expression|Start-Process|iex)\s+", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.ProcessExecution,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["process", "execution", "powershell"],
FileExtensions = new HashSet<string> { ".ps1", ".psm1" }
}
];
public ProcessExecutionCollector(ILogger<ProcessExecutionCollector> logger) : base(logger)
{
}
/// <inheritdoc />
public override string CollectorId => "surface.process-execution";
/// <inheritdoc />
public override string DisplayName => "Process Execution Collector";
/// <inheritdoc />
public override IReadOnlySet<SurfaceType> SupportedTypes { get; } =
new HashSet<SurfaceType> { SurfaceType.ProcessExecution };
/// <inheritdoc />
protected override IReadOnlyList<SurfacePattern> Patterns => s_patterns;
}

View File

@@ -0,0 +1,173 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Collectors;
/// <summary>
/// Collector for secret/credential access surface entries.
/// Detects patterns involving API keys, passwords, tokens, and sensitive data handling.
/// </summary>
public sealed class SecretAccessCollector : PatternBasedSurfaceCollector
{
private static readonly IReadOnlyList<SurfacePattern> s_patterns =
[
// Environment variable access for secrets
new SurfacePattern
{
Id = "env-secret-access",
Pattern = new Regex(@"(?:process\.env|Environment\.GetEnvironmentVariable|os\.(?:environ|getenv)|System\.getenv)\s*[\[\(]\s*[""'](?:.*(?:SECRET|PASSWORD|API_KEY|TOKEN|CREDENTIAL|AUTH|PRIVATE_KEY)[^""']*)[""']", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "environment", "credential"]
},
// Generic password/secret variables
new SurfacePattern
{
Id = "password-variable",
Pattern = new Regex(@"(?:password|passwd|pwd|secret|apikey|api_key|auth_token|access_token|private_key|secret_key)\s*[:=]", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.Medium,
Tags = ["secret", "password", "credential"],
ContextBoostPattern = new Regex(@"(?:config|settings|auth|credential|secret)", RegexOptions.Compiled | RegexOptions.IgnoreCase)
},
// Connection strings
new SurfacePattern
{
Id = "connection-string",
Pattern = new Regex(@"(?:connection[_-]?string|conn[_-]?str|database[_-]?url|db[_-]?url)\s*[:=]", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.High,
Tags = ["secret", "connection", "database"]
},
// AWS credentials
new SurfacePattern
{
Id = "aws-credentials",
Pattern = new Regex(@"(?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|aws_access_key|aws_secret_key)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "aws", "cloud", "credential"]
},
// Azure credentials
new SurfacePattern
{
Id = "azure-credentials",
Pattern = new Regex(@"(?:AZURE_CLIENT_SECRET|AZURE_TENANT_ID|AZURE_SUBSCRIPTION_ID)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "azure", "cloud", "credential"]
},
// GCP credentials
new SurfacePattern
{
Id = "gcp-credentials",
Pattern = new Regex(@"(?:GOOGLE_APPLICATION_CREDENTIALS|GCP_SERVICE_ACCOUNT|gcloud[_-]?key)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "gcp", "cloud", "credential"]
},
// Bearer token handling
new SurfacePattern
{
Id = "bearer-token",
Pattern = new Regex(@"[""']Bearer\s+", RegexOptions.Compiled),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.High,
Tags = ["secret", "token", "auth", "bearer"]
},
// JWT handling
new SurfacePattern
{
Id = "jwt-secret",
Pattern = new Regex(@"(?:jwt[_-]?secret|signing[_-]?key|jwt[_-]?key)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "jwt", "token", "signing"]
},
// Vault/secret manager access
new SurfacePattern
{
Id = "secret-manager",
Pattern = new Regex(@"(?:vault\.read|secretsmanager|keyvault|secret[_-]?manager)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "vault", "secret-manager"]
},
// Hardcoded secrets (base64-like patterns)
new SurfacePattern
{
Id = "hardcoded-key",
Pattern = new Regex(@"(?:api[_-]?key|secret[_-]?key|private[_-]?key)\s*[:=]\s*[""'][A-Za-z0-9+/=]{20,}[""']", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "hardcoded", "credential", "critical"]
},
// Private key file references
new SurfacePattern
{
Id = "private-key-file",
Pattern = new Regex(@"(?:-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----)|(?:\.pem|\.key|\.p12|\.pfx)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.High,
Tags = ["secret", "private-key", "certificate"]
},
// OAuth client secrets
new SurfacePattern
{
Id = "oauth-secret",
Pattern = new Regex(@"(?:client[_-]?secret|oauth[_-]?secret|oidc[_-]?secret)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "oauth", "credential"]
},
// Database password patterns
new SurfacePattern
{
Id = "db-password",
Pattern = new Regex(@"(?:db[_-]?password|database[_-]?password|mysql[_-]?password|postgres[_-]?password|mongo[_-]?password)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.VeryHigh,
Tags = ["secret", "database", "password"]
},
// Encryption key handling
new SurfacePattern
{
Id = "encryption-key",
Pattern = new Regex(@"(?:encryption[_-]?key|aes[_-]?key|master[_-]?key|data[_-]?key)", RegexOptions.Compiled | RegexOptions.IgnoreCase),
Type = SurfaceType.SecretAccess,
Confidence = ConfidenceLevel.High,
Tags = ["secret", "encryption", "crypto"]
}
];
public SecretAccessCollector(ILogger<SecretAccessCollector> logger) : base(logger)
{
}
/// <inheritdoc />
public override string CollectorId => "surface.secret-access";
/// <inheritdoc />
public override string DisplayName => "Secret Access Collector";
/// <inheritdoc />
public override IReadOnlySet<SurfaceType> SupportedTypes { get; } =
new HashSet<SurfaceType> { SurfaceType.SecretAccess };
/// <inheritdoc />
protected override IReadOnlyList<SurfacePattern> Patterns => s_patterns;
}

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Surface.Collectors;
using StellaOps.Scanner.Surface.Discovery;
using StellaOps.Scanner.Surface.Output;
using StellaOps.Scanner.Surface.Signals;
@@ -15,6 +17,7 @@ public static class SurfaceServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
// Core services
services.AddSingleton<ISurfaceEntryRegistry, SurfaceEntryRegistry>();
services.AddSingleton<ISurfaceSignalEmitter, SurfaceSignalEmitter>();
services.AddSingleton<ISurfaceAnalysisWriter, SurfaceAnalysisWriter>();
@@ -23,11 +26,32 @@ public static class SurfaceServiceCollectionExtensions
return services;
}
/// <summary>Adds surface analysis with all built-in collectors.</summary>
public static IServiceCollection AddSurfaceAnalysisWithDefaultCollectors(this IServiceCollection services)
{
services.AddSurfaceAnalysis();
// Built-in surface entry collectors
services.AddSurfaceCollector<NetworkEndpointCollector>();
services.AddSurfaceCollector<SecretAccessCollector>();
services.AddSurfaceCollector<ProcessExecutionCollector>();
services.AddSurfaceCollector<ExternalCallCollector>();
// Built-in entry point collectors
services.AddEntryPointCollector<NodeJsEntryPointCollector>();
// Register hosted service to initialize collectors
services.TryAddSingleton<SurfaceCollectorInitializer>();
return services;
}
/// <summary>Adds a surface entry collector.</summary>
public static IServiceCollection AddSurfaceCollector<T>(this IServiceCollection services)
where T : class, ISurfaceEntryCollector
{
services.AddSingleton<ISurfaceEntryCollector, T>();
services.AddSingleton<T>();
return services;
}
@@ -36,6 +60,48 @@ public static class SurfaceServiceCollectionExtensions
where T : class, IEntryPointCollector
{
services.AddSingleton<IEntryPointCollector, T>();
services.AddSingleton<T>();
return services;
}
}
/// <summary>
/// Initializer that registers all collectors with the registry.
/// Call Initialize() at application startup after DI container is built.
/// </summary>
public sealed class SurfaceCollectorInitializer
{
private readonly ISurfaceEntryRegistry _registry;
private readonly IEnumerable<ISurfaceEntryCollector> _collectors;
private readonly IEnumerable<IEntryPointCollector> _entryPointCollectors;
private bool _initialized;
public SurfaceCollectorInitializer(
ISurfaceEntryRegistry registry,
IEnumerable<ISurfaceEntryCollector> collectors,
IEnumerable<IEntryPointCollector> entryPointCollectors)
{
_registry = registry;
_collectors = collectors;
_entryPointCollectors = entryPointCollectors;
}
/// <summary>Initializes the registry with all registered collectors.</summary>
public void Initialize()
{
if (_initialized)
return;
foreach (var collector in _collectors)
{
_registry.RegisterCollector(collector);
}
foreach (var collector in _entryPointCollectors)
{
_registry.RegisterEntryPointCollector(collector);
}
_initialized = true;
}
}