feat(api): Add Policy Registry API specification
Some checks failed
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
mock-dev-release / package-mock-release (push) Has been cancelled

- Introduced OpenAPI specification for the StellaOps Policy Registry API, covering endpoints for verification policies, policy packs, snapshots, violations, overrides, sealed mode operations, and advisory staleness tracking.
- Defined schemas, parameters, and responses for comprehensive API documentation.

chore(scanner): Add global usings for scanner analyzers

- Created GlobalUsings.cs to simplify namespace usage across analyzer libraries.

feat(scanner): Implement Surface Service Collection Extensions

- Added SurfaceServiceCollectionExtensions for dependency injection registration of surface analysis services.
- Included methods for adding surface analysis, surface collectors, and entry point collectors to the service collection.
This commit is contained in:
StellaOps Bot
2025-12-06 20:52:23 +02:00
parent 05597616d6
commit f6c22854a4
37 changed files with 5664 additions and 1263 deletions

View File

@@ -0,0 +1,6 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;

View File

@@ -1,171 +0,0 @@
using StellaOps.Scanner.Surface.Discovery;
namespace StellaOps.Scanner.Surface.Models;
/// <summary>
/// Complete result of surface analysis for a scan.
/// </summary>
public sealed record SurfaceAnalysisResult
{
/// <summary>
/// Scan identifier.
/// </summary>
public required string ScanId { get; init; }
/// <summary>
/// When analysis was performed.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Analysis summary statistics.
/// </summary>
public required SurfaceAnalysisSummary Summary { get; init; }
/// <summary>
/// Discovered surface entries.
/// </summary>
public required IReadOnlyList<SurfaceEntry> Entries { get; init; }
/// <summary>
/// Discovered entry points.
/// </summary>
public IReadOnlyList<EntryPoint>? EntryPoints { get; init; }
/// <summary>
/// Analysis metadata.
/// </summary>
public SurfaceAnalysisMetadata? Metadata { get; init; }
}
/// <summary>
/// Summary statistics for surface analysis.
/// </summary>
public sealed record SurfaceAnalysisSummary
{
/// <summary>
/// Total number of surface entries.
/// </summary>
public required int TotalEntries { get; init; }
/// <summary>
/// Entry counts by type.
/// </summary>
public required IReadOnlyDictionary<SurfaceType, int> ByType { get; init; }
/// <summary>
/// Entry counts by confidence level.
/// </summary>
public required IReadOnlyDictionary<ConfidenceLevel, int> ByConfidence { get; init; }
/// <summary>
/// Calculated risk score (0.0 - 1.0).
/// </summary>
public required double RiskScore { get; init; }
/// <summary>
/// High-risk entry count.
/// </summary>
public int HighRiskCount { get; init; }
/// <summary>
/// Total entry points discovered.
/// </summary>
public int? EntryPointCount { get; init; }
/// <summary>
/// Creates summary from entries.
/// </summary>
public static SurfaceAnalysisSummary FromEntries(IReadOnlyList<SurfaceEntry> entries)
{
var byType = entries
.GroupBy(e => e.Type)
.ToDictionary(g => g.Key, g => g.Count());
var byConfidence = entries
.GroupBy(e => e.Confidence)
.ToDictionary(g => g.Key, g => g.Count());
// Calculate risk score based on entry types and confidence
var riskScore = CalculateRiskScore(entries);
var highRiskCount = entries.Count(e =>
e.Type is SurfaceType.ProcessExecution or SurfaceType.CryptoOperation or SurfaceType.SecretAccess ||
e.Confidence == ConfidenceLevel.Verified);
return new SurfaceAnalysisSummary
{
TotalEntries = entries.Count,
ByType = byType,
ByConfidence = byConfidence,
RiskScore = riskScore,
HighRiskCount = highRiskCount
};
}
private static double CalculateRiskScore(IReadOnlyList<SurfaceEntry> entries)
{
if (entries.Count == 0) return 0.0;
var typeWeights = new Dictionary<SurfaceType, double>
{
[SurfaceType.ProcessExecution] = 1.0,
[SurfaceType.SecretAccess] = 0.9,
[SurfaceType.CryptoOperation] = 0.8,
[SurfaceType.DatabaseOperation] = 0.7,
[SurfaceType.Deserialization] = 0.85,
[SurfaceType.DynamicCode] = 0.9,
[SurfaceType.AuthenticationPoint] = 0.6,
[SurfaceType.NetworkEndpoint] = 0.5,
[SurfaceType.InputHandling] = 0.5,
[SurfaceType.ExternalCall] = 0.4,
[SurfaceType.FileOperation] = 0.3
};
var confidenceMultipliers = new Dictionary<ConfidenceLevel, double>
{
[ConfidenceLevel.Low] = 0.5,
[ConfidenceLevel.Medium] = 0.75,
[ConfidenceLevel.High] = 1.0,
[ConfidenceLevel.Verified] = 1.0
};
var totalWeight = entries.Sum(e =>
typeWeights.GetValueOrDefault(e.Type, 0.3) *
confidenceMultipliers.GetValueOrDefault(e.Confidence, 0.5));
// Normalize to 0-1 range (cap at 100 weighted entries)
return Math.Min(1.0, totalWeight / 100.0);
}
}
/// <summary>
/// Metadata about the surface analysis execution.
/// </summary>
public sealed record SurfaceAnalysisMetadata
{
/// <summary>
/// Analysis duration in milliseconds.
/// </summary>
public double DurationMs { get; init; }
/// <summary>
/// Files analyzed count.
/// </summary>
public int FilesAnalyzed { get; init; }
/// <summary>
/// Languages detected.
/// </summary>
public IReadOnlyList<string>? Languages { get; init; }
/// <summary>
/// Frameworks detected.
/// </summary>
public IReadOnlyList<string>? Frameworks { get; init; }
/// <summary>
/// Analysis configuration used.
/// </summary>
public SurfaceAnalysisOptions? Options { get; init; }
}

View File

@@ -1,121 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Output;
/// <summary>
/// Interface for writing surface analysis results.
/// </summary>
public interface ISurfaceAnalysisWriter
{
/// <summary>
/// Writes analysis result to the specified stream.
/// </summary>
Task WriteAsync(
SurfaceAnalysisResult result,
Stream outputStream,
CancellationToken cancellationToken = default);
/// <summary>
/// Serializes analysis result to JSON string.
/// </summary>
string Serialize(SurfaceAnalysisResult result);
}
/// <summary>
/// Store key for surface analysis results.
/// </summary>
public static class SurfaceAnalysisStoreKeys
{
/// <summary>
/// Key for storing surface analysis in scan artifacts.
/// </summary>
public const string SurfaceAnalysis = "scanner.surface.analysis";
/// <summary>
/// Key for storing surface entries.
/// </summary>
public const string SurfaceEntries = "scanner.surface.entries";
/// <summary>
/// Key for storing entry points.
/// </summary>
public const string EntryPoints = "scanner.surface.entrypoints";
}
/// <summary>
/// Default implementation of surface analysis writer.
/// Uses deterministic JSON serialization.
/// </summary>
public sealed class SurfaceAnalysisWriter : ISurfaceAnalysisWriter
{
private readonly ILogger<SurfaceAnalysisWriter> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
private static readonly JsonSerializerOptions PrettyJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public SurfaceAnalysisWriter(ILogger<SurfaceAnalysisWriter> logger)
{
_logger = logger;
}
public async Task WriteAsync(
SurfaceAnalysisResult result,
Stream outputStream,
CancellationToken cancellationToken = default)
{
// Sort entries by ID for determinism
var sortedResult = SortResult(result);
await JsonSerializer.SerializeAsync(
outputStream,
sortedResult,
JsonOptions,
cancellationToken);
_logger.LogDebug(
"Wrote surface analysis for scan {ScanId} with {EntryCount} entries",
result.ScanId,
result.Entries.Count);
}
public string Serialize(SurfaceAnalysisResult result)
{
var sortedResult = SortResult(result);
return JsonSerializer.Serialize(sortedResult, PrettyJsonOptions);
}
private static SurfaceAnalysisResult SortResult(SurfaceAnalysisResult result)
{
// Sort entries by ID for deterministic output
var sortedEntries = result.Entries
.OrderBy(e => e.Id)
.ToList();
// Sort entry points by ID if present
var sortedEntryPoints = result.EntryPoints?
.OrderBy(ep => ep.Id)
.ToList();
return result with
{
Entries = sortedEntries,
EntryPoints = sortedEntryPoints
};
}
}

View File

@@ -80,7 +80,7 @@ public sealed class SurfaceAnalysisWriter : ISurfaceAnalysisWriter
var jsonOptions = options.PrettyPrint ? s_prettyJsonOptions : s_jsonOptions;
if (options.WriteToFile && \!string.IsNullOrEmpty(options.OutputPath))
if (options.WriteToFile && !string.IsNullOrEmpty(options.OutputPath))
{
var filePath = Path.Combine(options.OutputPath, $"surface-{result.ScanId}.json");
await using var stream = File.Create(filePath);

View File

@@ -1,153 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Scanner.Surface.Discovery;
using StellaOps.Scanner.Surface.Output;
using StellaOps.Scanner.Surface.Signals;
namespace StellaOps.Scanner.Surface;
/// <summary>
/// Extension methods for registering surface analysis services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds surface analysis services to the service collection.
/// </summary>
public static IServiceCollection AddSurfaceAnalysis(
this IServiceCollection services,
IConfiguration? configuration = null)
{
// Core services
services.TryAddSingleton<ISurfaceEntryRegistry, SurfaceEntryRegistry>();
services.TryAddSingleton<ISurfaceSignalEmitter, SurfaceSignalEmitter>();
services.TryAddSingleton<ISurfaceAnalysisWriter, SurfaceAnalysisWriter>();
services.TryAddSingleton<ISurfaceAnalyzer, SurfaceAnalyzer>();
// Configure options if configuration provided
if (configuration != null)
{
services.Configure<SurfaceAnalysisOptions>(
configuration.GetSection("Scanner:Surface"));
}
return services;
}
/// <summary>
/// Adds surface analysis services with a signal sink.
/// </summary>
public static IServiceCollection AddSurfaceAnalysis<TSignalSink>(
this IServiceCollection services,
IConfiguration? configuration = null)
where TSignalSink : class, ISurfaceSignalSink
{
services.AddSurfaceAnalysis(configuration);
services.TryAddSingleton<ISurfaceSignalSink, TSignalSink>();
return services;
}
/// <summary>
/// Adds surface analysis services with in-memory signal sink for testing.
/// </summary>
public static IServiceCollection AddSurfaceAnalysisForTesting(this IServiceCollection services)
{
services.AddSurfaceAnalysis();
services.TryAddSingleton<ISurfaceSignalSink, InMemorySurfaceSignalSink>();
return services;
}
/// <summary>
/// Registers a surface entry collector.
/// </summary>
public static IServiceCollection AddSurfaceCollector<TCollector>(this IServiceCollection services)
where TCollector : class, ISurfaceEntryCollector
{
services.AddSingleton<ISurfaceEntryCollector, TCollector>();
return services;
}
/// <summary>
/// Registers multiple surface entry collectors.
/// </summary>
public static IServiceCollection AddSurfaceCollectors(
this IServiceCollection services,
params Type[] collectorTypes)
{
foreach (var type in collectorTypes)
{
if (!typeof(ISurfaceEntryCollector).IsAssignableFrom(type))
{
throw new ArgumentException(
$"Type {type.Name} does not implement ISurfaceEntryCollector",
nameof(collectorTypes));
}
services.AddSingleton(typeof(ISurfaceEntryCollector), type);
}
return services;
}
}
/// <summary>
/// Builder for configuring surface analysis.
/// </summary>
public sealed class SurfaceAnalysisBuilder
{
private readonly IServiceCollection _services;
internal SurfaceAnalysisBuilder(IServiceCollection services)
{
_services = services;
}
/// <summary>
/// Registers a collector.
/// </summary>
public SurfaceAnalysisBuilder AddCollector<TCollector>()
where TCollector : class, ISurfaceEntryCollector
{
_services.AddSurfaceCollector<TCollector>();
return this;
}
/// <summary>
/// Configures a custom signal sink.
/// </summary>
public SurfaceAnalysisBuilder UseSignalSink<TSignalSink>()
where TSignalSink : class, ISurfaceSignalSink
{
_services.TryAddSingleton<ISurfaceSignalSink, TSignalSink>();
return this;
}
/// <summary>
/// Configures options.
/// </summary>
public SurfaceAnalysisBuilder Configure(Action<SurfaceAnalysisOptions> configure)
{
_services.Configure(configure);
return this;
}
}
/// <summary>
/// Extension for fluent builder pattern.
/// </summary>
public static class SurfaceAnalysisBuilderExtensions
{
/// <summary>
/// Adds surface analysis with fluent configuration.
/// </summary>
public static IServiceCollection AddSurfaceAnalysis(
this IServiceCollection services,
Action<SurfaceAnalysisBuilder> configure)
{
services.AddSurfaceAnalysis();
var builder = new SurfaceAnalysisBuilder(services);
configure(builder);
return services;
}
}

View File

@@ -1,177 +0,0 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Signals;
/// <summary>
/// Interface for emitting surface analysis signals for policy evaluation.
/// </summary>
public interface ISurfaceSignalEmitter
{
/// <summary>
/// Emits signals for the given analysis result.
/// </summary>
Task EmitAsync(
string scanId,
SurfaceAnalysisResult result,
CancellationToken cancellationToken = default);
/// <summary>
/// Emits custom signals.
/// </summary>
Task EmitAsync(
string scanId,
IDictionary<string, object> signals,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of surface signal emitter.
/// Converts analysis results to policy signals.
/// </summary>
public sealed class SurfaceSignalEmitter : ISurfaceSignalEmitter
{
private readonly ILogger<SurfaceSignalEmitter> _logger;
private readonly ISurfaceSignalSink? _sink;
public SurfaceSignalEmitter(
ILogger<SurfaceSignalEmitter> logger,
ISurfaceSignalSink? sink = null)
{
_logger = logger;
_sink = sink;
}
public async Task EmitAsync(
string scanId,
SurfaceAnalysisResult result,
CancellationToken cancellationToken = default)
{
var signals = BuildSignals(result);
await EmitAsync(scanId, signals, cancellationToken);
}
public async Task EmitAsync(
string scanId,
IDictionary<string, object> signals,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Emitting {SignalCount} surface signals for scan {ScanId}",
signals.Count,
scanId);
if (_sink != null)
{
await _sink.WriteAsync(scanId, signals, cancellationToken);
}
else
{
_logger.LogDebug(
"No signal sink configured, signals for scan {ScanId}: {Signals}",
scanId,
string.Join(", ", signals.Select(kv => $"{kv.Key}={kv.Value}")));
}
}
private static Dictionary<string, object> BuildSignals(SurfaceAnalysisResult result)
{
var signals = new Dictionary<string, object>
{
[SurfaceSignalKeys.TotalSurfaceArea] = result.Summary.TotalEntries,
[SurfaceSignalKeys.RiskScore] = result.Summary.RiskScore,
[SurfaceSignalKeys.HighConfidenceCount] = result.Entries
.Count(e => e.Confidence >= ConfidenceLevel.High)
};
// Add counts by type
foreach (var (type, count) in result.Summary.ByType)
{
var key = type switch
{
SurfaceType.NetworkEndpoint => SurfaceSignalKeys.NetworkEndpoints,
SurfaceType.FileOperation => SurfaceSignalKeys.FileOperations,
SurfaceType.ProcessExecution => SurfaceSignalKeys.ProcessSpawns,
SurfaceType.CryptoOperation => SurfaceSignalKeys.CryptoUsage,
SurfaceType.AuthenticationPoint => SurfaceSignalKeys.AuthPoints,
SurfaceType.InputHandling => SurfaceSignalKeys.InputHandlers,
SurfaceType.SecretAccess => SurfaceSignalKeys.SecretAccess,
SurfaceType.ExternalCall => SurfaceSignalKeys.ExternalCalls,
SurfaceType.DatabaseOperation => SurfaceSignalKeys.DatabaseOperations,
SurfaceType.Deserialization => SurfaceSignalKeys.DeserializationPoints,
SurfaceType.DynamicCode => SurfaceSignalKeys.DynamicCodePoints,
_ => $"{SurfaceSignalKeys.Prefix}{type.ToString().ToLowerInvariant()}"
};
signals[key] = count;
}
// Add entry point count if available
if (result.EntryPoints is { Count: > 0 })
{
signals[SurfaceSignalKeys.EntryPointCount] = result.EntryPoints.Count;
}
// Add framework signals if metadata available
if (result.Metadata?.Frameworks is { Count: > 0 } frameworks)
{
foreach (var framework in frameworks)
{
var normalizedName = framework.ToLowerInvariant().Replace(" ", "_").Replace(".", "_");
signals[$"{SurfaceSignalKeys.FrameworkPrefix}{normalizedName}"] = true;
}
}
// Add language signals if metadata available
if (result.Metadata?.Languages is { Count: > 0 } languages)
{
foreach (var language in languages)
{
var normalizedName = language.ToLowerInvariant();
signals[$"{SurfaceSignalKeys.LanguagePrefix}{normalizedName}"] = true;
}
}
return signals;
}
}
/// <summary>
/// Sink for writing surface signals to storage.
/// </summary>
public interface ISurfaceSignalSink
{
/// <summary>
/// Writes signals to storage.
/// </summary>
Task WriteAsync(
string scanId,
IDictionary<string, object> signals,
CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory signal sink for testing.
/// </summary>
public sealed class InMemorySurfaceSignalSink : ISurfaceSignalSink
{
private readonly Dictionary<string, IDictionary<string, object>> _signals = new();
public IReadOnlyDictionary<string, IDictionary<string, object>> Signals => _signals;
public Task WriteAsync(
string scanId,
IDictionary<string, object> signals,
CancellationToken cancellationToken = default)
{
_signals[scanId] = new Dictionary<string, object>(signals);
return Task.CompletedTask;
}
public IDictionary<string, object>? GetSignals(string scanId)
{
return _signals.TryGetValue(scanId, out var signals) ? signals : null;
}
public void Clear() => _signals.Clear();
}

View File

@@ -1,64 +0,0 @@
namespace StellaOps.Scanner.Surface.Signals;
/// <summary>
/// Standard signal keys for surface analysis policy integration.
/// </summary>
public static class SurfaceSignalKeys
{
/// <summary>Prefix for all surface signals.</summary>
public const string Prefix = "surface.";
/// <summary>Network endpoint count.</summary>
public const string NetworkEndpoints = "surface.network.endpoints";
/// <summary>Exposed port count.</summary>
public const string ExposedPorts = "surface.network.ports";
/// <summary>File operation count.</summary>
public const string FileOperations = "surface.file.operations";
/// <summary>Process spawn count.</summary>
public const string ProcessSpawns = "surface.process.spawns";
/// <summary>Crypto operation count.</summary>
public const string CryptoUsage = "surface.crypto.usage";
/// <summary>Authentication point count.</summary>
public const string AuthPoints = "surface.auth.points";
/// <summary>Input handler count.</summary>
public const string InputHandlers = "surface.input.handlers";
/// <summary>Secret access point count.</summary>
public const string SecretAccess = "surface.secrets.access";
/// <summary>External call count.</summary>
public const string ExternalCalls = "surface.external.calls";
/// <summary>Database operation count.</summary>
public const string DatabaseOperations = "surface.database.operations";
/// <summary>Deserialization point count.</summary>
public const string DeserializationPoints = "surface.deserialization.points";
/// <summary>Dynamic code execution count.</summary>
public const string DynamicCodePoints = "surface.dynamic.code";
/// <summary>Total surface area score.</summary>
public const string TotalSurfaceArea = "surface.total.area";
/// <summary>Overall risk score (0.0-1.0).</summary>
public const string RiskScore = "surface.risk.score";
/// <summary>High-confidence entry count.</summary>
public const string HighConfidenceCount = "surface.high_confidence.count";
/// <summary>Entry point count.</summary>
public const string EntryPointCount = "surface.entry_points.count";
/// <summary>Framework-specific prefix.</summary>
public const string FrameworkPrefix = "surface.framework.";
/// <summary>Language-specific prefix.</summary>
public const string LanguagePrefix = "surface.language.";
}

View File

@@ -16,7 +16,6 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="System.Text.Json" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Surface.FS\StellaOps.Scanner.Surface.FS.csproj" />

View File

@@ -7,21 +7,41 @@ using StellaOps.Scanner.Surface.Signals;
namespace StellaOps.Scanner.Surface;
/// <summary>
/// Main interface for surface analysis operations.
/// Options for surface analysis execution.
/// </summary>
public sealed record SurfaceAnalysisOptions
{
/// <summary>Collector options.</summary>
public SurfaceCollectorOptions CollectorOptions { get; init; } = new();
/// <summary>Output options.</summary>
public SurfaceOutputOptions OutputOptions { get; init; } = new();
/// <summary>Whether to emit policy signals.</summary>
public bool EmitSignals { get; init; } = true;
/// <summary>Whether to discover entry points.</summary>
public bool DiscoverEntryPoints { get; init; } = true;
/// <summary>Languages to analyze for entry points.</summary>
public IReadOnlySet<string> Languages { get; init; } = new HashSet<string>();
}
/// <summary>
/// Interface for orchestrating surface analysis.
/// </summary>
public interface ISurfaceAnalyzer
{
/// <summary>
/// Performs surface analysis on the given context.
/// </summary>
/// <summary>Runs surface analysis on the specified path.</summary>
Task<SurfaceAnalysisResult> AnalyzeAsync(
SurfaceCollectionContext context,
string scanId,
string rootPath,
SurfaceAnalysisOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of surface analyzer.
/// Coordinates collectors, signal emission, and output writing.
/// Default surface analyzer implementation.
/// </summary>
public sealed class SurfaceAnalyzer : ISurfaceAnalyzer
{
@@ -43,59 +63,152 @@ public sealed class SurfaceAnalyzer : ISurfaceAnalyzer
}
public async Task<SurfaceAnalysisResult> AnalyzeAsync(
SurfaceCollectionContext context,
string scanId,
string rootPath,
SurfaceAnalysisOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
options ??= new SurfaceAnalysisOptions();
var startTime = DateTimeOffset.UtcNow;
_logger.LogInformation("Starting surface analysis for scan {ScanId} at {RootPath}", scanId, rootPath);
_logger.LogInformation(
"Starting surface analysis for scan {ScanId} with {FileCount} files",
context.ScanId,
context.Files.Count);
// Collect entries from all applicable collectors
var entries = new List<SurfaceEntry>();
await foreach (var entry in _registry.CollectAllAsync(context, cancellationToken))
var context = new SurfaceCollectorContext
{
entries.Add(entry);
ScanId = scanId,
RootPath = rootPath,
Options = options.CollectorOptions
};
// Collect surface entries
var entries = new List<SurfaceEntry>();
var collectors = _registry.GetCollectors();
_logger.LogDebug("Running {CollectorCount} surface collectors", collectors.Count);
foreach (var collector in collectors)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await foreach (var entry in collector.CollectAsync(context, cancellationToken))
{
entries.Add(entry);
}
_logger.LogDebug("Collector {CollectorId} found {Count} entries", collector.CollectorId, entries.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Collector {CollectorId} failed", collector.CollectorId);
}
}
_logger.LogDebug(
"Collected {EntryCount} surface entries for scan {ScanId}",
entries.Count,
context.ScanId);
// Collect entry points
var entryPoints = new List<EntryPoint>();
if (options.DiscoverEntryPoints)
{
var epCollectors = _registry.GetEntryPointCollectors();
_logger.LogDebug("Running {Count} entry point collectors", epCollectors.Count);
foreach (var collector in epCollectors)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await foreach (var ep in collector.CollectAsync(context, cancellationToken))
{
entryPoints.Add(ep);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Entry point collector {CollectorId} failed", collector.CollectorId);
}
}
}
// Sort entries by ID for determinism
entries.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.Ordinal));
entryPoints.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.Ordinal));
// Build summary
var summary = SurfaceAnalysisSummary.FromEntries(entries);
var byType = entries.GroupBy(e => e.Type).ToDictionary(g => g.Key, g => g.Count());
var summary = new SurfaceAnalysisSummary
{
TotalEntries = entries.Count,
ByType = byType,
RiskScore = CalculateRiskScore(entries, entryPoints)
};
// Create result
var result = new SurfaceAnalysisResult
{
ScanId = context.ScanId,
ScanId = scanId,
Timestamp = DateTimeOffset.UtcNow,
Summary = summary,
Entries = entries,
Metadata = new SurfaceAnalysisMetadata
{
DurationMs = (DateTimeOffset.UtcNow - startTime).TotalMilliseconds,
FilesAnalyzed = context.Files.Count,
Languages = context.DetectedLanguages,
Frameworks = context.DetectedFrameworks,
Options = context.Options
}
EntryPoints = entryPoints
};
// Emit signals for policy evaluation
await _signalEmitter.EmitAsync(context.ScanId, result, cancellationToken);
// Emit signals
if (options.EmitSignals)
{
var signals = SurfaceSignalEmitter.BuildSignals(result);
await _signalEmitter.EmitAsync(scanId, signals, cancellationToken);
}
// Write output
await _writer.WriteAsync(result, options.OutputOptions, cancellationToken);
_logger.LogInformation(
"Completed surface analysis for scan {ScanId}: {TotalEntries} entries, risk score {RiskScore:F2}",
context.ScanId,
result.Summary.TotalEntries,
result.Summary.RiskScore);
"Surface analysis complete: {EntryCount} entries, {EntryPointCount} entry points, risk score {RiskScore:F2}",
entries.Count, entryPoints.Count, summary.RiskScore);
return result;
}
private static double CalculateRiskScore(IReadOnlyList<SurfaceEntry> entries, IReadOnlyList<EntryPoint> entryPoints)
{
if (entries.Count == 0 && entryPoints.Count == 0)
return 0.0;
// Weight high-risk types more heavily
var riskWeights = new Dictionary<SurfaceType, double>
{
[SurfaceType.SecretAccess] = 1.0,
[SurfaceType.AuthenticationPoint] = 0.9,
[SurfaceType.ProcessExecution] = 0.8,
[SurfaceType.CryptoOperation] = 0.7,
[SurfaceType.ExternalCall] = 0.6,
[SurfaceType.NetworkEndpoint] = 0.5,
[SurfaceType.InputHandling] = 0.5,
[SurfaceType.FileOperation] = 0.3
};
double totalWeight = 0;
double weightedSum = 0;
foreach (var entry in entries)
{
var weight = riskWeights.GetValueOrDefault(entry.Type, 0.5);
var confidence = entry.Confidence switch
{
ConfidenceLevel.VeryHigh => 1.0,
ConfidenceLevel.High => 0.8,
ConfidenceLevel.Medium => 0.5,
ConfidenceLevel.Low => 0.2,
_ => 0.5
};
weightedSum += weight * confidence;
totalWeight += 1.0;
}
// Entry points add to risk
weightedSum += entryPoints.Count * 0.3;
totalWeight += entryPoints.Count * 0.5;
return totalWeight > 0 ? Math.Min(1.0, weightedSum / totalWeight) : 0.0;
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.Surface.Discovery;
using StellaOps.Scanner.Surface.Output;
using StellaOps.Scanner.Surface.Signals;
namespace StellaOps.Scanner.Surface;
/// <summary>
/// DI registration extensions for Scanner Surface analysis.
/// </summary>
public static class SurfaceServiceCollectionExtensions
{
/// <summary>Adds surface analysis services to the service collection.</summary>
public static IServiceCollection AddSurfaceAnalysis(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<ISurfaceEntryRegistry, SurfaceEntryRegistry>();
services.AddSingleton<ISurfaceSignalEmitter, SurfaceSignalEmitter>();
services.AddSingleton<ISurfaceAnalysisWriter, SurfaceAnalysisWriter>();
services.AddSingleton<ISurfaceAnalyzer, SurfaceAnalyzer>();
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>();
return services;
}
/// <summary>Adds an entry point collector.</summary>
public static IServiceCollection AddEntryPointCollector<T>(this IServiceCollection services)
where T : class, IEntryPointCollector
{
services.AddSingleton<IEntryPointCollector, T>();
return services;
}
}