feat: Add Go module and workspace test fixtures

- Created expected JSON files for Go modules and workspaces.
- Added go.mod and go.sum files for example projects.
- Implemented private module structure with expected JSON output.
- Introduced vendored dependencies with corresponding expected JSON.
- Developed PostgresGraphJobStore for managing graph jobs.
- Established SQL migration scripts for graph jobs schema.
- Implemented GraphJobRepository for CRUD operations on graph jobs.
- Created IGraphJobRepository interface for repository abstraction.
- Added unit tests for GraphJobRepository to ensure functionality.
This commit is contained in:
StellaOps Bot
2025-12-06 20:04:03 +02:00
parent a6f1406509
commit 05597616d6
178 changed files with 12022 additions and 4545 deletions

View File

@@ -18,7 +18,94 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var candidatePaths = new List<string>(GoBinaryScanner.EnumerateCandidateFiles(context.RootPath));
// Track emitted modules to avoid duplicates (binary takes precedence over source)
var emittedModules = new HashSet<string>(StringComparer.Ordinal);
// Phase 1: Source scanning (go.mod, go.sum, go.work, vendor)
ScanSourceFiles(context, writer, emittedModules, cancellationToken);
// Phase 2: Binary scanning (existing behavior)
ScanBinaries(context, writer, emittedModules, cancellationToken);
return ValueTask.CompletedTask;
}
private void ScanSourceFiles(
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
HashSet<string> emittedModules,
CancellationToken cancellationToken)
{
// Discover Go projects
var projects = GoProjectDiscoverer.Discover(context.RootPath, cancellationToken);
if (projects.Count == 0)
{
return;
}
foreach (var project in projects)
{
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<GoSourceInventory.SourceInventoryResult> inventories;
if (project.IsWorkspace)
{
// Handle workspace with multiple modules
inventories = GoSourceInventory.BuildWorkspaceInventory(project, cancellationToken);
}
else
{
// Single module
var inventory = GoSourceInventory.BuildInventory(project);
inventories = inventory.IsEmpty
? Array.Empty<GoSourceInventory.SourceInventoryResult>()
: new[] { inventory };
}
foreach (var inventory in inventories)
{
if (inventory.IsEmpty)
{
continue;
}
// Emit the main module
if (!string.IsNullOrEmpty(inventory.ModulePath))
{
EmitMainModuleFromSource(inventory, project, context, writer, emittedModules);
}
// Emit dependencies
foreach (var module in inventory.Modules.OrderBy(m => m.Path, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
EmitSourceModule(module, inventory, project, context, writer, emittedModules);
}
}
}
}
private void ScanBinaries(
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
HashSet<string> emittedModules,
CancellationToken cancellationToken)
{
var candidatePaths = new List<string>();
// Use binary format pre-filtering for efficiency
foreach (var path in GoBinaryScanner.EnumerateCandidateFiles(context.RootPath))
{
cancellationToken.ThrowIfCancellationRequested();
// Quick check for known binary formats
if (GoBinaryFormatDetector.IsPotentialBinary(path))
{
candidatePaths.Add(path);
}
}
candidatePaths.Sort(StringComparer.Ordinal);
var fallbackBinaries = new List<GoStrippedBinaryClassification>();
@@ -37,7 +124,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
continue;
}
EmitComponents(buildInfo, context, writer);
EmitComponents(buildInfo, context, writer, emittedModules);
}
foreach (var fallback in fallbackBinaries)
@@ -45,11 +132,197 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
cancellationToken.ThrowIfCancellationRequested();
EmitFallbackComponent(fallback, context, writer);
}
return ValueTask.CompletedTask;
}
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer)
private void EmitMainModuleFromSource(
GoSourceInventory.SourceInventoryResult inventory,
GoProjectDiscoverer.GoProject project,
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
HashSet<string> emittedModules)
{
// Main module from go.mod (typically no version in source)
var modulePath = inventory.ModulePath!;
var moduleKey = $"{modulePath}@(devel)";
if (!emittedModules.Add(moduleKey))
{
return; // Already emitted
}
var relativePath = context.GetRelativePath(project.RootPath);
var goModRelative = project.HasGoMod ? context.GetRelativePath(project.GoModPath!) : null;
var metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal)
{
["modulePath"] = modulePath,
["modulePath.main"] = modulePath,
["provenance"] = "source"
};
if (!string.IsNullOrEmpty(inventory.GoVersion))
{
metadata["go.version"] = inventory.GoVersion;
}
if (!string.IsNullOrEmpty(relativePath))
{
metadata["projectPath"] = relativePath;
}
if (project.IsWorkspace)
{
metadata["workspace"] = "true";
}
var evidence = new List<LanguageComponentEvidence>();
if (!string.IsNullOrEmpty(goModRelative))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"go.mod",
goModRelative,
modulePath,
null));
}
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
// Main module typically has (devel) as version in source context
writer.AddFromExplicitKey(
analyzerId: Id,
componentKey: $"golang::source::{modulePath}::(devel)",
purl: null,
name: modulePath,
version: "(devel)",
type: "golang",
metadata: metadata,
evidence: evidence);
}
private void EmitSourceModule(
GoSourceInventory.GoSourceModule module,
GoSourceInventory.SourceInventoryResult inventory,
GoProjectDiscoverer.GoProject project,
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
HashSet<string> emittedModules)
{
var moduleKey = $"{module.Path}@{module.Version}";
if (!emittedModules.Add(moduleKey))
{
return; // Already emitted (binary takes precedence)
}
var purl = BuildPurl(module.Path, module.Version);
var goModRelative = project.HasGoMod ? context.GetRelativePath(project.GoModPath!) : null;
var metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal)
{
["modulePath"] = module.Path,
["moduleVersion"] = module.Version,
["provenance"] = "source"
};
if (!string.IsNullOrEmpty(module.Checksum))
{
metadata["moduleSum"] = module.Checksum;
}
if (module.IsDirect)
{
metadata["dependency.direct"] = "true";
}
if (module.IsIndirect)
{
metadata["dependency.indirect"] = "true";
}
if (module.IsVendored)
{
metadata["vendored"] = "true";
}
if (module.IsPrivate)
{
metadata["private"] = "true";
}
if (module.ModuleCategory != "public")
{
metadata["moduleCategory"] = module.ModuleCategory;
}
if (!string.IsNullOrEmpty(module.Registry))
{
metadata["registry"] = module.Registry;
}
if (module.IsReplaced)
{
metadata["replaced"] = "true";
if (!string.IsNullOrEmpty(module.ReplacementPath))
{
metadata["replacedBy.path"] = module.ReplacementPath;
}
if (!string.IsNullOrEmpty(module.ReplacementVersion))
{
metadata["replacedBy.version"] = module.ReplacementVersion;
}
}
if (module.IsExcluded)
{
metadata["excluded"] = "true";
}
var evidence = new List<LanguageComponentEvidence>();
// Evidence from go.mod
if (!string.IsNullOrEmpty(goModRelative))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
module.Source,
goModRelative,
$"{module.Path}@{module.Version}",
module.Checksum));
}
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
if (!string.IsNullOrEmpty(purl))
{
writer.AddFromPurl(
analyzerId: Id,
purl: purl,
name: module.Path,
version: module.Version,
type: "golang",
metadata: metadata,
evidence: evidence,
usedByEntrypoint: false);
}
else
{
writer.AddFromExplicitKey(
analyzerId: Id,
componentKey: $"golang::source::{module.Path}@{module.Version}",
purl: null,
name: module.Path,
version: module.Version,
type: "golang",
metadata: metadata,
evidence: evidence);
}
}
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer, HashSet<string> emittedModules)
{
var components = new List<GoModule> { buildInfo.MainModule };
components.AddRange(buildInfo.Dependencies
@@ -61,6 +334,10 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
foreach (var module in components)
{
// Track emitted modules (binary evidence is more accurate than source)
var moduleKey = $"{module.Path}@{module.Version ?? "(devel)"}";
emittedModules.Add(moduleKey);
var metadata = BuildMetadata(buildInfo, module, binaryRelativePath);
var evidence = BuildEvidence(buildInfo, module, binaryRelativePath, context, ref binaryHash);
var usedByEntrypoint = module.IsMain && context.UsageHints.IsPathUsed(buildInfo.AbsoluteBinaryPath);

View File

@@ -0,0 +1,301 @@
using System.Buffers;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Detects binary file formats to quickly filter candidates for Go binary scanning.
/// Identifies ELF (Linux), PE (Windows), and Mach-O (macOS) formats.
/// </summary>
internal static class GoBinaryFormatDetector
{
// Magic bytes for different formats
private static readonly byte[] ElfMagic = [0x7F, (byte)'E', (byte)'L', (byte)'F'];
private static readonly byte[] PeMagic = [(byte)'M', (byte)'Z'];
private static readonly byte[] MachO32Magic = [0xFE, 0xED, 0xFA, 0xCE];
private static readonly byte[] MachO64Magic = [0xFE, 0xED, 0xFA, 0xCF];
private static readonly byte[] MachO32MagicReverse = [0xCE, 0xFA, 0xED, 0xFE];
private static readonly byte[] MachO64MagicReverse = [0xCF, 0xFA, 0xED, 0xFE];
private static readonly byte[] FatMagic = [0xCA, 0xFE, 0xBA, 0xBE]; // Universal binary
/// <summary>
/// Binary format type.
/// </summary>
public enum BinaryFormat
{
Unknown,
Elf,
Pe,
MachO,
Fat // Universal/Fat binary (contains multiple architectures)
}
/// <summary>
/// Result of binary format detection.
/// </summary>
public readonly record struct DetectionResult(
BinaryFormat Format,
bool IsExecutable,
string? Architecture);
/// <summary>
/// Quickly checks if a file is likely a binary executable.
/// </summary>
public static bool IsPotentialBinary(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
return false;
}
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
if (stream.Length < 4)
{
return false;
}
Span<byte> header = stackalloc byte[4];
var read = stream.Read(header);
if (read < 4)
{
return false;
}
return IsKnownBinaryFormat(header);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}
/// <summary>
/// Detects the binary format and extracts basic metadata.
/// </summary>
public static DetectionResult Detect(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
return new DetectionResult(BinaryFormat.Unknown, false, null);
}
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return DetectFromStream(stream);
}
catch (IOException)
{
return new DetectionResult(BinaryFormat.Unknown, false, null);
}
catch (UnauthorizedAccessException)
{
return new DetectionResult(BinaryFormat.Unknown, false, null);
}
}
/// <summary>
/// Detects format from a stream.
/// </summary>
public static DetectionResult DetectFromStream(Stream stream)
{
if (stream.Length < 64)
{
return new DetectionResult(BinaryFormat.Unknown, false, null);
}
var buffer = ArrayPool<byte>.Shared.Rent(64);
try
{
var read = stream.Read(buffer, 0, 64);
if (read < 4)
{
return new DetectionResult(BinaryFormat.Unknown, false, null);
}
var header = new ReadOnlySpan<byte>(buffer, 0, read);
// Check ELF
if (header[..4].SequenceEqual(ElfMagic))
{
return DetectElf(header);
}
// Check PE (MZ header)
if (header[..2].SequenceEqual(PeMagic))
{
return DetectPe(header, stream);
}
// Check Mach-O
if (header[..4].SequenceEqual(MachO32Magic) ||
header[..4].SequenceEqual(MachO64Magic) ||
header[..4].SequenceEqual(MachO32MagicReverse) ||
header[..4].SequenceEqual(MachO64MagicReverse))
{
return DetectMachO(header);
}
// Check Fat binary
if (header[..4].SequenceEqual(FatMagic))
{
return new DetectionResult(BinaryFormat.Fat, true, "universal");
}
return new DetectionResult(BinaryFormat.Unknown, false, null);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static bool IsKnownBinaryFormat(ReadOnlySpan<byte> header)
{
if (header.Length < 4)
{
return false;
}
// ELF
if (header[..4].SequenceEqual(ElfMagic))
{
return true;
}
// PE
if (header[..2].SequenceEqual(PeMagic))
{
return true;
}
// Mach-O (all variants)
if (header[..4].SequenceEqual(MachO32Magic) ||
header[..4].SequenceEqual(MachO64Magic) ||
header[..4].SequenceEqual(MachO32MagicReverse) ||
header[..4].SequenceEqual(MachO64MagicReverse) ||
header[..4].SequenceEqual(FatMagic))
{
return true;
}
return false;
}
private static DetectionResult DetectElf(ReadOnlySpan<byte> header)
{
if (header.Length < 20)
{
return new DetectionResult(BinaryFormat.Elf, true, null);
}
// ELF class (32 or 64 bit)
var elfClass = header[4];
var is64Bit = elfClass == 2;
// ELF type (offset 16-17)
var elfType = header[16];
var isExecutable = elfType == 2 || elfType == 3; // ET_EXEC or ET_DYN
// Machine type (offset 18-19)
var machine = header[18];
var arch = machine switch
{
0x03 => "386",
0x3E => "amd64",
0xB7 => "arm64",
0x28 => "arm",
0xF3 => "riscv64",
0x08 => "mips",
0x14 => "ppc",
0x15 => "ppc64",
0x16 => "s390x",
_ => is64Bit ? "64-bit" : "32-bit"
};
return new DetectionResult(BinaryFormat.Elf, isExecutable, arch);
}
private static DetectionResult DetectPe(ReadOnlySpan<byte> header, Stream stream)
{
// PE files have PE\0\0 signature at offset specified in header
if (header.Length < 64)
{
return new DetectionResult(BinaryFormat.Pe, true, null);
}
// Get PE header offset from offset 0x3C
var peOffset = BitConverter.ToInt32(header.Slice(0x3C, 4));
if (peOffset < 0 || peOffset > stream.Length - 6)
{
return new DetectionResult(BinaryFormat.Pe, true, null);
}
// Read PE header
stream.Position = peOffset;
Span<byte> peHeader = stackalloc byte[6];
if (stream.Read(peHeader) < 6)
{
return new DetectionResult(BinaryFormat.Pe, true, null);
}
// Verify PE signature
if (peHeader[0] != 'P' || peHeader[1] != 'E' || peHeader[2] != 0 || peHeader[3] != 0)
{
return new DetectionResult(BinaryFormat.Pe, true, null);
}
// Machine type
var machine = BitConverter.ToUInt16(peHeader.Slice(4, 2));
var arch = machine switch
{
0x014C => "386",
0x8664 => "amd64",
0xAA64 => "arm64",
0x01C4 => "arm",
_ => null
};
return new DetectionResult(BinaryFormat.Pe, true, arch);
}
private static DetectionResult DetectMachO(ReadOnlySpan<byte> header)
{
if (header.Length < 8)
{
return new DetectionResult(BinaryFormat.MachO, true, null);
}
// Check endianness and word size
var is64Bit = header[..4].SequenceEqual(MachO64Magic) || header[..4].SequenceEqual(MachO64MagicReverse);
var isLittleEndian = header[..4].SequenceEqual(MachO32MagicReverse) || header[..4].SequenceEqual(MachO64MagicReverse);
// CPU type is at offset 4
int cpuType;
if (isLittleEndian)
{
cpuType = BitConverter.ToInt32(header.Slice(4, 4));
}
else
{
// Big endian
cpuType = (header[4] << 24) | (header[5] << 16) | (header[6] << 8) | header[7];
}
var arch = (cpuType & 0xFF) switch
{
7 => is64Bit ? "amd64" : "386",
12 => is64Bit ? "arm64" : "arm",
18 => is64Bit ? "ppc64" : "ppc",
_ => is64Bit ? "64-bit" : "32-bit"
};
return new DetectionResult(BinaryFormat.MachO, true, arch);
}
}

View File

@@ -0,0 +1,243 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Aggregates Go module dependencies from source files (go.mod, go.sum, vendor/modules.txt).
/// </summary>
internal static class GoSourceInventory
{
/// <summary>
/// A Go module discovered from source files.
/// </summary>
public sealed record GoSourceModule
{
public required string Path { get; init; }
public required string Version { get; init; }
public string? Checksum { get; init; }
public bool IsDirect { get; init; }
public bool IsIndirect { get; init; }
public bool IsVendored { get; init; }
public bool IsReplaced { get; init; }
public bool IsExcluded { get; init; }
public bool IsRetracted { get; init; }
public bool IsPrivate { get; init; }
public string? ReplacementPath { get; init; }
public string? ReplacementVersion { get; init; }
public string Source { get; init; } = "go.mod";
public string ModuleCategory { get; init; } = "public";
public string? Registry { get; init; }
}
/// <summary>
/// Inventory results from source scanning.
/// </summary>
public sealed record SourceInventoryResult
{
public static readonly SourceInventoryResult Empty = new(
null,
null,
ImmutableArray<GoSourceModule>.Empty,
ImmutableArray<string>.Empty);
public SourceInventoryResult(
string? modulePath,
string? goVersion,
ImmutableArray<GoSourceModule> modules,
ImmutableArray<string> retractedVersions)
{
ModulePath = modulePath;
GoVersion = goVersion;
Modules = modules;
RetractedVersions = retractedVersions;
}
public string? ModulePath { get; }
public string? GoVersion { get; }
public ImmutableArray<GoSourceModule> Modules { get; }
public ImmutableArray<string> RetractedVersions { get; }
public bool IsEmpty => Modules.IsEmpty && string.IsNullOrEmpty(ModulePath);
}
/// <summary>
/// Builds inventory from a discovered Go project.
/// </summary>
public static SourceInventoryResult BuildInventory(GoProjectDiscoverer.GoProject project)
{
ArgumentNullException.ThrowIfNull(project);
if (!project.HasGoMod)
{
return SourceInventoryResult.Empty;
}
// Parse go.mod
var goMod = GoModParser.Parse(project.GoModPath!);
if (goMod.IsEmpty)
{
return SourceInventoryResult.Empty;
}
// Parse go.sum for checksums
var goSum = project.HasGoSum
? GoSumParser.Parse(project.GoSumPath!)
: GoSumParser.GoSumData.Empty;
// Parse vendor/modules.txt if present
var vendorData = project.HasVendor
? GoVendorParser.Parse(project.VendorModulesPath!)
: GoVendorParser.GoVendorData.Empty;
// Build replacement map
var replacements = goMod.Replaces
.ToImmutableDictionary(
r => r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath,
r => r,
StringComparer.Ordinal);
// Build exclude set
var excludes = goMod.Excludes
.Select(e => $"{e.Path}@{e.Version}")
.ToImmutableHashSet(StringComparer.Ordinal);
// Build retracted set (these are versions of this module that are retracted)
var retractedVersions = goMod.Retracts.ToImmutableArray();
// Process requires
var modules = new List<GoSourceModule>();
foreach (var req in goMod.Requires)
{
var checksum = goSum.GetHash(req.Path, req.Version);
var isVendored = vendorData.IsVendored(req.Path);
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(req.Path);
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(req.Path);
var registry = GoPrivateModuleDetector.GetRegistry(req.Path);
// Check for replacement
GoModParser.GoModReplace? replacement = null;
var versionedKey = $"{req.Path}@{req.Version}";
if (replacements.TryGetValue(versionedKey, out replacement) ||
replacements.TryGetValue(req.Path, out replacement))
{
// Module is replaced
}
// Check if excluded
var isExcluded = excludes.Contains(versionedKey);
var module = new GoSourceModule
{
Path = req.Path,
Version = req.Version,
Checksum = checksum,
IsDirect = !req.IsIndirect,
IsIndirect = req.IsIndirect,
IsVendored = isVendored,
IsReplaced = replacement is not null,
IsExcluded = isExcluded,
IsRetracted = false, // Can't know without checking the module's go.mod
IsPrivate = isPrivate,
ReplacementPath = replacement?.NewPath,
ReplacementVersion = replacement?.NewVersion,
Source = isVendored ? "vendor" : "go.mod",
ModuleCategory = moduleCategory,
Registry = registry
};
modules.Add(module);
}
// Add vendored modules not in requires (explicit vendored deps)
if (!vendorData.IsEmpty)
{
var requirePaths = goMod.Requires
.Select(r => r.Path)
.ToImmutableHashSet(StringComparer.Ordinal);
foreach (var vendorMod in vendorData.Modules)
{
if (!requirePaths.Contains(vendorMod.Path))
{
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(vendorMod.Path);
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(vendorMod.Path);
modules.Add(new GoSourceModule
{
Path = vendorMod.Path,
Version = vendorMod.Version,
Checksum = goSum.GetHash(vendorMod.Path, vendorMod.Version),
IsDirect = vendorMod.IsExplicit,
IsIndirect = !vendorMod.IsExplicit,
IsVendored = true,
IsReplaced = false,
IsExcluded = false,
IsRetracted = false,
IsPrivate = isPrivate,
Source = "vendor",
ModuleCategory = moduleCategory
});
}
}
}
return new SourceInventoryResult(
goMod.ModulePath,
goMod.GoVersion,
modules.ToImmutableArray(),
retractedVersions);
}
/// <summary>
/// Builds combined inventory for a workspace (all members).
/// </summary>
public static IReadOnlyList<SourceInventoryResult> BuildWorkspaceInventory(
GoProjectDiscoverer.GoProject workspaceProject,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(workspaceProject);
var results = new List<SourceInventoryResult>();
// Build inventory for workspace root if it has go.mod
if (workspaceProject.HasGoMod)
{
var rootInventory = BuildInventory(workspaceProject);
if (!rootInventory.IsEmpty)
{
results.Add(rootInventory);
}
}
// Build inventory for each workspace member
foreach (var memberPath in workspaceProject.WorkspaceMembers)
{
cancellationToken.ThrowIfCancellationRequested();
var memberFullPath = Path.Combine(workspaceProject.RootPath, memberPath);
var memberGoMod = Path.Combine(memberFullPath, "go.mod");
var memberGoSum = Path.Combine(memberFullPath, "go.sum");
var memberVendor = Path.Combine(memberFullPath, "vendor", "modules.txt");
var memberProject = new GoProjectDiscoverer.GoProject(
memberFullPath,
File.Exists(memberGoMod) ? memberGoMod : null,
File.Exists(memberGoSum) ? memberGoSum : null,
null,
File.Exists(memberVendor) ? memberVendor : null,
ImmutableArray<string>.Empty);
if (memberProject.HasGoMod)
{
var memberInventory = BuildInventory(memberProject);
if (!memberInventory.IsEmpty)
{
results.Add(memberInventory);
}
}
}
return results;
}
}

View File

@@ -3,143 +3,83 @@ using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Discovery;
/// <summary>
/// Interface for collecting surface entries from specific sources.
/// Collectors are language/framework-specific implementations that
/// discover attack surface entry points.
/// Options for surface entry collection.
/// </summary>
public sealed record SurfaceCollectorOptions
{
/// <summary>Maximum call graph depth to analyze.</summary>
public int MaxDepth { get; init; } = 3;
/// <summary>Minimum confidence threshold for reporting.</summary>
public double MinimumConfidence { get; init; } = 0.7;
/// <summary>Surface types to include (empty = all).</summary>
public IReadOnlySet<SurfaceType> IncludeTypes { get; init; } = new HashSet<SurfaceType>();
/// <summary>Surface types to exclude.</summary>
public IReadOnlySet<SurfaceType> ExcludeTypes { get; init; } = new HashSet<SurfaceType>();
/// <summary>Whether to include code snippets in evidence.</summary>
public bool IncludeSnippets { get; init; } = true;
/// <summary>Maximum snippet length.</summary>
public int MaxSnippetLength { get; init; } = 500;
}
/// <summary>
/// Context provided to surface entry collectors.
/// </summary>
public sealed record SurfaceCollectorContext
{
/// <summary>Scan identifier.</summary>
public required string ScanId { get; init; }
/// <summary>Root path being scanned.</summary>
public required string RootPath { get; init; }
/// <summary>Collector options.</summary>
public required SurfaceCollectorOptions Options { get; init; }
/// <summary>Optional tenant identifier.</summary>
public string? TenantId { get; init; }
/// <summary>Additional context metadata.</summary>
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
/// <summary>
/// Interface for surface entry collectors that detect specific attack surface patterns.
/// </summary>
public interface ISurfaceEntryCollector
{
/// <summary>
/// Unique identifier for this collector.
/// </summary>
/// <summary>Unique identifier for this collector.</summary>
string CollectorId { get; }
/// <summary>
/// Display name for this collector.
/// </summary>
string Name { get; }
/// <summary>Human-readable name.</summary>
string DisplayName { get; }
/// <summary>
/// Languages supported by this collector.
/// </summary>
IReadOnlyList<string> SupportedLanguages { get; }
/// <summary>Surface types this collector can detect.</summary>
IReadOnlySet<SurfaceType> SupportedTypes { get; }
/// <summary>
/// Surface types this collector can detect.
/// </summary>
IReadOnlyList<SurfaceType> DetectableTypes { get; }
/// <summary>
/// Priority for collector ordering (higher = run first).
/// </summary>
int Priority { get; }
/// <summary>
/// Determines if this collector can analyze the given context.
/// </summary>
bool CanCollect(SurfaceCollectionContext context);
/// <summary>
/// Collects surface entries from the given context.
/// </summary>
/// <summary>Collects surface entries from the given context.</summary>
IAsyncEnumerable<SurfaceEntry> CollectAsync(
SurfaceCollectionContext context,
SurfaceCollectorContext context,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Context for surface entry collection.
/// Interface for entry point collectors that discover application entry points.
/// </summary>
public sealed record SurfaceCollectionContext
public interface IEntryPointCollector
{
/// <summary>
/// Scan identifier.
/// </summary>
public required string ScanId { get; init; }
/// <summary>Unique identifier for this collector.</summary>
string CollectorId { get; }
/// <summary>
/// Root directory being scanned.
/// </summary>
public required string RootPath { get; init; }
/// <summary>Languages/frameworks this collector supports.</summary>
IReadOnlySet<string> SupportedLanguages { get; }
/// <summary>
/// Files to analyze (relative paths).
/// </summary>
public required IReadOnlyList<string> Files { get; init; }
/// <summary>
/// Detected languages in the codebase.
/// </summary>
public IReadOnlyList<string>? DetectedLanguages { get; init; }
/// <summary>
/// Detected frameworks.
/// </summary>
public IReadOnlyList<string>? DetectedFrameworks { get; init; }
/// <summary>
/// Analysis options.
/// </summary>
public SurfaceAnalysisOptions? Options { get; init; }
/// <summary>
/// Additional context data.
/// </summary>
public IReadOnlyDictionary<string, object>? Data { get; init; }
}
/// <summary>
/// Options for surface analysis.
/// </summary>
public sealed record SurfaceAnalysisOptions
{
/// <summary>
/// Whether surface analysis is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Call graph depth for analysis.
/// </summary>
public int Depth { get; init; } = 3;
/// <summary>
/// Minimum confidence threshold for reporting.
/// </summary>
public double ConfidenceThreshold { get; init; } = 0.7;
/// <summary>
/// Surface types to include (null = all).
/// </summary>
public IReadOnlyList<SurfaceType>? IncludeTypes { get; init; }
/// <summary>
/// Surface types to exclude.
/// </summary>
public IReadOnlyList<SurfaceType>? ExcludeTypes { get; init; }
/// <summary>
/// Maximum entries to collect.
/// </summary>
public int? MaxEntries { get; init; }
/// <summary>
/// File patterns to include.
/// </summary>
public IReadOnlyList<string>? IncludePatterns { get; init; }
/// <summary>
/// File patterns to exclude.
/// </summary>
public IReadOnlyList<string>? ExcludePatterns { get; init; }
/// <summary>
/// Collectors to use (null = all registered).
/// </summary>
public IReadOnlyList<string>? Collectors { get; init; }
/// <summary>
/// Default analysis options.
/// </summary>
public static SurfaceAnalysisOptions Default => new();
/// <summary>Collects entry points from the given context.</summary>
IAsyncEnumerable<EntryPoint> CollectAsync(
SurfaceCollectorContext context,
CancellationToken cancellationToken = default);
}

View File

@@ -1,36 +1,30 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Discovery;
/// <summary>
/// Registry for surface entry collectors.
/// Manages collector registration and orchestrates collection.
/// Registry for surface entry and entry point collectors.
/// </summary>
public interface ISurfaceEntryRegistry
{
/// <summary>
/// Registers a collector.
/// </summary>
void Register(ISurfaceEntryCollector collector);
/// <summary>Registers a surface entry collector.</summary>
void RegisterCollector(ISurfaceEntryCollector collector);
/// <summary>
/// Gets all registered collectors.
/// </summary>
/// <summary>Registers an entry point collector.</summary>
void RegisterEntryPointCollector(IEntryPointCollector collector);
/// <summary>Gets all registered surface entry collectors.</summary>
IReadOnlyList<ISurfaceEntryCollector> GetCollectors();
/// <summary>
/// Gets collectors that can analyze the given context.
/// </summary>
IReadOnlyList<ISurfaceEntryCollector> GetApplicableCollectors(SurfaceCollectionContext context);
/// <summary>Gets all registered entry point collectors.</summary>
IReadOnlyList<IEntryPointCollector> GetEntryPointCollectors();
/// <summary>
/// Collects entries using all applicable collectors.
/// </summary>
IAsyncEnumerable<SurfaceEntry> CollectAllAsync(
SurfaceCollectionContext context,
CancellationToken cancellationToken = default);
/// <summary>Gets collectors that support the specified surface type.</summary>
IReadOnlyList<ISurfaceEntryCollector> GetCollectorsForType(SurfaceType type);
/// <summary>Gets entry point collectors that support the specified language.</summary>
IReadOnlyList<IEntryPointCollector> GetEntryPointCollectorsForLanguage(string language);
}
/// <summary>
@@ -39,6 +33,7 @@ public interface ISurfaceEntryRegistry
public sealed class SurfaceEntryRegistry : ISurfaceEntryRegistry
{
private readonly List<ISurfaceEntryCollector> _collectors = [];
private readonly List<IEntryPointCollector> _entryPointCollectors = [];
private readonly ILogger<SurfaceEntryRegistry> _logger;
private readonly object _lock = new();
@@ -47,141 +42,61 @@ public sealed class SurfaceEntryRegistry : ISurfaceEntryRegistry
_logger = logger;
}
public void Register(ISurfaceEntryCollector collector)
public void RegisterCollector(ISurfaceEntryCollector collector)
{
ArgumentNullException.ThrowIfNull(collector);
lock (_lock)
{
// Check for duplicate
if (_collectors.Any(c => c.CollectorId == collector.CollectorId))
{
_logger.LogWarning(
"Collector {CollectorId} already registered, skipping duplicate",
collector.CollectorId);
_logger.LogWarning("Collector {CollectorId} already registered, skipping", collector.CollectorId);
return;
}
_collectors.Add(collector);
_logger.LogDebug(
"Registered surface collector {CollectorId} ({Name}) for languages: {Languages}",
collector.CollectorId,
collector.Name,
string.Join(", ", collector.SupportedLanguages));
_logger.LogDebug("Registered surface collector: {CollectorId}", collector.CollectorId);
}
}
public void RegisterEntryPointCollector(IEntryPointCollector collector)
{
ArgumentNullException.ThrowIfNull(collector);
lock (_lock)
{
if (_entryPointCollectors.Any(c => c.CollectorId == collector.CollectorId))
{
_logger.LogWarning("Entry point collector {CollectorId} already registered, skipping", collector.CollectorId);
return;
}
_entryPointCollectors.Add(collector);
_logger.LogDebug("Registered entry point collector: {CollectorId}", collector.CollectorId);
}
}
public IReadOnlyList<ISurfaceEntryCollector> GetCollectors()
{
lock (_lock) return [.. _collectors];
}
public IReadOnlyList<IEntryPointCollector> GetEntryPointCollectors()
{
lock (_lock) return [.. _entryPointCollectors];
}
public IReadOnlyList<ISurfaceEntryCollector> GetCollectorsForType(SurfaceType type)
{
lock (_lock)
{
return _collectors
.OrderByDescending(c => c.Priority)
.ToList();
return [.. _collectors.Where(c => c.SupportedTypes.Contains(type))];
}
}
public IReadOnlyList<ISurfaceEntryCollector> GetApplicableCollectors(SurfaceCollectionContext context)
public IReadOnlyList<IEntryPointCollector> GetEntryPointCollectorsForLanguage(string language)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentException.ThrowIfNullOrWhiteSpace(language);
lock (_lock)
{
var applicable = _collectors
.Where(c => c.CanCollect(context))
.OrderByDescending(c => c.Priority)
.ToList();
// Filter by options if specified
if (context.Options?.Collectors is { Count: > 0 } allowedCollectors)
{
applicable = applicable
.Where(c => allowedCollectors.Contains(c.CollectorId))
.ToList();
}
return applicable;
return [.. _entryPointCollectors.Where(c =>
c.SupportedLanguages.Contains(language, StringComparer.OrdinalIgnoreCase))];
}
}
public async IAsyncEnumerable<SurfaceEntry> CollectAllAsync(
SurfaceCollectionContext context,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var collectors = GetApplicableCollectors(context);
if (collectors.Count == 0)
{
_logger.LogDebug("No applicable collectors for scan {ScanId}", context.ScanId);
yield break;
}
_logger.LogDebug(
"Running {CollectorCount} collectors for scan {ScanId}",
collectors.Count,
context.ScanId);
var seenIds = new HashSet<string>();
var entryCount = 0;
var maxEntries = context.Options?.MaxEntries;
foreach (var collector in collectors)
{
if (cancellationToken.IsCancellationRequested)
break;
if (maxEntries.HasValue && entryCount >= maxEntries.Value)
{
_logger.LogDebug(
"Reached max entries limit ({MaxEntries}) for scan {ScanId}",
maxEntries.Value,
context.ScanId);
break;
}
_logger.LogDebug(
"Running collector {CollectorId} for scan {ScanId}",
collector.CollectorId,
context.ScanId);
await foreach (var entry in collector.CollectAsync(context, cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
break;
// Apply confidence threshold
if (context.Options?.ConfidenceThreshold is double threshold)
{
var confidenceValue = (int)entry.Confidence / 4.0;
if (confidenceValue < threshold)
continue;
}
// Apply type filters
if (context.Options?.ExcludeTypes?.Contains(entry.Type) == true)
continue;
if (context.Options?.IncludeTypes is { Count: > 0 } includeTypes &&
!includeTypes.Contains(entry.Type))
continue;
// Deduplicate by ID
if (!seenIds.Add(entry.Id))
continue;
entryCount++;
yield return entry;
if (maxEntries.HasValue && entryCount >= maxEntries.Value)
break;
}
}
_logger.LogDebug(
"Collected {EntryCount} surface entries for scan {ScanId}",
entryCount,
context.ScanId);
}
}

View File

@@ -1,115 +1,76 @@
namespace StellaOps.Scanner.Surface.Models;
/// <summary>
/// Represents a discovered entry point in application code.
/// Entry points are language/framework-specific handlers that
/// receive external input (HTTP routes, RPC handlers, etc.).
/// An application entry point discovered during surface analysis.
/// </summary>
public sealed record EntryPoint
{
/// <summary>
/// Unique identifier for this entry point.
/// </summary>
/// <summary>Unique identifier.</summary>
public required string Id { get; init; }
/// <summary>
/// Programming language.
/// </summary>
/// <summary>Programming language.</summary>
public required string Language { get; init; }
/// <summary>
/// Web framework or runtime (e.g., "ASP.NET Core", "Express", "FastAPI").
/// </summary>
public required string Framework { get; init; }
/// <summary>Framework or runtime.</summary>
public string? Framework { get; init; }
/// <summary>
/// URL path or route pattern.
/// </summary>
/// <summary>URL path or route pattern.</summary>
public required string Path { get; init; }
/// <summary>
/// HTTP method (GET, POST, etc.) or RPC method type.
/// </summary>
public required string Method { get; init; }
/// <summary>HTTP method or RPC method name.</summary>
public string? Method { get; init; }
/// <summary>
/// Handler function/method name.
/// </summary>
/// <summary>Handler function/method name.</summary>
public required string Handler { get; init; }
/// <summary>
/// Source file containing the handler.
/// </summary>
/// <summary>Source file containing the handler.</summary>
public required string File { get; init; }
/// <summary>
/// Line number of the handler definition.
/// </summary>
public required int Line { get; init; }
/// <summary>Line number of the handler definition.</summary>
public int Line { get; init; }
/// <summary>
/// Handler parameters/arguments.
/// </summary>
/// <summary>Parameter names/types.</summary>
public IReadOnlyList<string> Parameters { get; init; } = [];
/// <summary>
/// Middleware chain applied to this endpoint.
/// </summary>
/// <summary>Applied middleware/interceptors.</summary>
public IReadOnlyList<string> Middlewares { get; init; } = [];
/// <summary>
/// Whether authentication is required.
/// </summary>
public bool? RequiresAuth { get; init; }
/// <summary>
/// Authorization policies applied.
/// </summary>
public IReadOnlyList<string>? AuthorizationPolicies { get; init; }
/// <summary>
/// Content types accepted.
/// </summary>
public IReadOnlyList<string>? AcceptsContentTypes { get; init; }
/// <summary>
/// Content types produced.
/// </summary>
public IReadOnlyList<string>? ProducesContentTypes { get; init; }
}
/// <summary>
/// Result of entry point discovery for a scan.
/// Summary of surface analysis results.
/// </summary>
public sealed record EntryPointDiscoveryResult
public sealed record SurfaceAnalysisSummary
{
/// <summary>
/// Scan identifier.
/// </summary>
/// <summary>Total number of entries detected.</summary>
public int TotalEntries { get; init; }
/// <summary>Entries grouped by type.</summary>
public IReadOnlyDictionary<SurfaceType, int> ByType { get; init; } = new Dictionary<SurfaceType, int>();
/// <summary>Overall risk score (0.0 to 1.0).</summary>
public double RiskScore { get; init; }
}
/// <summary>
/// Complete surface analysis result for a scan.
/// </summary>
public sealed record SurfaceAnalysisResult
{
/// <summary>Key for storing analysis results.</summary>
public const string StoreKey = "scanner.surface.analysis";
/// <summary>Scan identifier.</summary>
public required string ScanId { get; init; }
/// <summary>
/// When discovery was performed.
/// </summary>
public required DateTimeOffset DiscoveredAt { get; init; }
/// <summary>Analysis timestamp (UTC).</summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Discovered entry points.
/// </summary>
public required IReadOnlyList<EntryPoint> EntryPoints { get; init; }
/// <summary>Analysis summary.</summary>
public required SurfaceAnalysisSummary Summary { get; init; }
/// <summary>
/// Frameworks detected.
/// </summary>
public required IReadOnlyList<string> DetectedFrameworks { get; init; }
/// <summary>Detected surface entries.</summary>
public required IReadOnlyList<SurfaceEntry> Entries { get; init; }
/// <summary>
/// Total entry points by method.
/// </summary>
public required IReadOnlyDictionary<string, int> ByMethod { get; init; }
/// <summary>
/// Warnings or issues during discovery.
/// </summary>
public IReadOnlyList<string>? Warnings { get; init; }
/// <summary>Discovered entry points.</summary>
public IReadOnlyList<EntryPoint> EntryPoints { get; init; } = [];
}

View File

@@ -3,124 +3,58 @@ using System.Text;
namespace StellaOps.Scanner.Surface.Models;
/// <summary>
/// Represents a discovered attack surface entry point.
/// </summary>
public sealed record SurfaceEntry
{
/// <summary>
/// Unique identifier: SHA256(type|path|context).
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Type classification of this surface entry.
/// </summary>
public required SurfaceType Type { get; init; }
/// <summary>
/// File path, URL endpoint, or resource identifier.
/// </summary>
public required string Path { get; init; }
/// <summary>
/// Function, method, or handler context.
/// </summary>
public required string Context { get; init; }
/// <summary>
/// Detection confidence level.
/// </summary>
public required ConfidenceLevel Confidence { get; init; }
/// <summary>
/// Tags for categorization and filtering.
/// </summary>
public IReadOnlyList<string> Tags { get; init; } = [];
/// <summary>
/// Evidence supporting this entry detection.
/// </summary>
public required SurfaceEvidence Evidence { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Creates a deterministic ID from type, path, and context.
/// </summary>
public static string ComputeId(SurfaceType type, string path, string context)
{
var input = $"{type}|{path}|{context}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Creates a new SurfaceEntry with computed ID.
/// </summary>
public static SurfaceEntry Create(
SurfaceType type,
string path,
string context,
ConfidenceLevel confidence,
SurfaceEvidence evidence,
IEnumerable<string>? tags = null,
IReadOnlyDictionary<string, string>? metadata = null)
{
return new SurfaceEntry
{
Id = ComputeId(type, path, context),
Type = type,
Path = path,
Context = context,
Confidence = confidence,
Evidence = evidence,
Tags = tags?.ToList() ?? [],
Metadata = metadata
};
}
}
/// <summary>
/// Evidence supporting a surface entry detection.
/// </summary>
public sealed record SurfaceEvidence
{
/// <summary>
/// Source file path.
/// </summary>
/// <summary>Source file path.</summary>
public required string File { get; init; }
/// <summary>
/// Line number in the source file.
/// </summary>
/// <summary>Line number in source file.</summary>
public required int Line { get; init; }
/// <summary>
/// Column number if available.
/// </summary>
public int? Column { get; init; }
/// <summary>Content hash of the evidence.</summary>
public required string Hash { get; init; }
/// <summary>
/// Content hash of the source file.
/// </summary>
public string? FileHash { get; init; }
/// <summary>
/// Code snippet around the detection.
/// </summary>
/// <summary>Optional code snippet.</summary>
public string? Snippet { get; init; }
/// <summary>
/// Detection method used.
/// </summary>
public string? DetectionMethod { get; init; }
/// <summary>
/// Additional evidence details.
/// </summary>
public IReadOnlyDictionary<string, string>? Details { get; init; }
/// <summary>Optional additional metadata.</summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// A detected surface analysis entry representing a potential attack surface.
/// </summary>
public sealed record SurfaceEntry
{
/// <summary>Deterministic ID: SHA256(type|path|context).</summary>
public required string Id { get; init; }
/// <summary>Type of surface entry.</summary>
public required SurfaceType Type { get; init; }
/// <summary>File path or endpoint path.</summary>
public required string Path { get; init; }
/// <summary>Function/method context where detected.</summary>
public required string Context { get; init; }
/// <summary>Detection confidence level.</summary>
public required ConfidenceLevel Confidence { get; init; }
/// <summary>Classification tags.</summary>
public IReadOnlyList<string> Tags { get; init; } = [];
/// <summary>Supporting evidence.</summary>
public required SurfaceEvidence Evidence { get; init; }
/// <summary>Creates a deterministic ID from components.</summary>
public static string ComputeId(SurfaceType type, string path, string context)
{
var input = $"{type}|{path}|{context}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -1,42 +1,33 @@
namespace StellaOps.Scanner.Surface.Models;
/// <summary>
/// Classification of attack surface entry types.
/// Surface analysis entry type classification.
/// </summary>
public enum SurfaceType
{
/// <summary>Network-exposed endpoints, listeners, ports.</summary>
/// <summary>Exposed network endpoints, ports, listeners.</summary>
NetworkEndpoint,
/// <summary>File system operations, path access.</summary>
/// <summary>File system operations, sensitive file access.</summary>
FileOperation,
/// <summary>Process/command execution, subprocess spawns.</summary>
/// <summary>Process execution, subprocess spawning.</summary>
ProcessExecution,
/// <summary>Cryptographic operations, key handling.</summary>
CryptoOperation,
/// <summary>Authentication entry points, session handling.</summary>
/// <summary>Authentication points, session handling.</summary>
AuthenticationPoint,
/// <summary>User input handling, injection points.</summary>
InputHandling,
/// <summary>Secret/credential access points.</summary>
/// <summary>Secret/credential access patterns.</summary>
SecretAccess,
/// <summary>External service calls, HTTP clients.</summary>
ExternalCall,
/// <summary>Database queries, ORM operations.</summary>
DatabaseOperation,
/// <summary>Deserialization points.</summary>
Deserialization,
/// <summary>Reflection/dynamic code execution.</summary>
DynamicCode
/// <summary>External service calls, outbound connections.</summary>
ExternalCall
}
/// <summary>
@@ -44,15 +35,15 @@ public enum SurfaceType
/// </summary>
public enum ConfidenceLevel
{
/// <summary>Low confidence - heuristic or pattern match.</summary>
Low = 1,
/// <summary>Low confidence, likely false positive.</summary>
Low,
/// <summary>Medium confidence - likely match.</summary>
Medium = 2,
/// <summary>Medium confidence, manual review recommended.</summary>
Medium,
/// <summary>High confidence - definite match.</summary>
High = 3,
/// <summary>High confidence, likely accurate.</summary>
High,
/// <summary>Verified - confirmed through multiple signals.</summary>
Verified = 4
/// <summary>Very high confidence, confirmed pattern.</summary>
VeryHigh
}

View File

@@ -0,0 +1,117 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Output;
/// <summary>
/// Options for surface analysis output.
/// </summary>
public sealed record SurfaceOutputOptions
{
/// <summary>Output directory path.</summary>
public string? OutputPath { get; init; }
/// <summary>Whether to write to file.</summary>
public bool WriteToFile { get; init; } = true;
/// <summary>Whether to emit NDJSON format.</summary>
public bool UseNdjson { get; init; }
/// <summary>Whether to include evidence snippets.</summary>
public bool IncludeSnippets { get; init; } = true;
/// <summary>Whether to pretty-print JSON output.</summary>
public bool PrettyPrint { get; init; }
}
/// <summary>
/// Interface for writing surface analysis results.
/// </summary>
public interface ISurfaceAnalysisWriter
{
/// <summary>Writes surface analysis result.</summary>
Task WriteAsync(
SurfaceAnalysisResult result,
SurfaceOutputOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>Writes surface entries as NDJSON stream.</summary>
IAsyncEnumerable<string> WriteNdjsonAsync(
SurfaceAnalysisResult result,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default surface analysis writer implementation.
/// </summary>
public sealed class SurfaceAnalysisWriter : ISurfaceAnalysisWriter
{
private readonly ILogger<SurfaceAnalysisWriter> _logger;
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
private static readonly JsonSerializerOptions s_prettyJsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
public SurfaceAnalysisWriter(ILogger<SurfaceAnalysisWriter> logger)
{
_logger = logger;
}
public async Task WriteAsync(
SurfaceAnalysisResult result,
SurfaceOutputOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
options ??= new SurfaceOutputOptions();
var jsonOptions = options.PrettyPrint ? s_prettyJsonOptions : s_jsonOptions;
if (options.WriteToFile && \!string.IsNullOrEmpty(options.OutputPath))
{
var filePath = Path.Combine(options.OutputPath, $"surface-{result.ScanId}.json");
await using var stream = File.Create(filePath);
await JsonSerializer.SerializeAsync(stream, result, jsonOptions, cancellationToken);
_logger.LogInformation("Wrote surface analysis to {FilePath}", filePath);
}
}
public async IAsyncEnumerable<string> WriteNdjsonAsync(
SurfaceAnalysisResult result,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(result);
// Emit summary first
yield return JsonSerializer.Serialize(new { type = "summary", data = result.Summary }, s_jsonOptions);
// Emit each entry
foreach (var entry in result.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
yield return JsonSerializer.Serialize(new { type = "entry", data = entry }, s_jsonOptions);
}
// Emit entry points
foreach (var ep in result.EntryPoints)
{
cancellationToken.ThrowIfCancellationRequested();
yield return JsonSerializer.Serialize(new { type = "entrypoint", data = ep }, s_jsonOptions);
}
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,102 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Models;
namespace StellaOps.Scanner.Surface.Signals;
/// <summary>
/// Standard surface signal keys for policy integration.
/// </summary>
public static class SurfaceSignalKeys
{
public const string NetworkEndpoints = "surface.network.endpoints";
public const string ExposedPorts = "surface.network.ports";
public const string FileOperations = "surface.file.operations";
public const string ProcessSpawns = "surface.process.spawns";
public const string CryptoUsage = "surface.crypto.usage";
public const string AuthPoints = "surface.auth.points";
public const string InputHandlers = "surface.input.handlers";
public const string SecretAccess = "surface.secrets.access";
public const string ExternalCalls = "surface.external.calls";
public const string TotalSurfaceArea = "surface.total.area";
public const string RiskScore = "surface.risk.score";
public const string EntryPointCount = "surface.entrypoints.count";
}
/// <summary>
/// Interface for emitting surface analysis signals to policy engine.
/// </summary>
public interface ISurfaceSignalEmitter
{
/// <summary>Emits surface signals for a scan.</summary>
Task EmitAsync(
string scanId,
IDictionary<string, object> signals,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default surface signal emitter implementation.
/// </summary>
public sealed class SurfaceSignalEmitter : ISurfaceSignalEmitter
{
private readonly ILogger<SurfaceSignalEmitter> _logger;
public SurfaceSignalEmitter(ILogger<SurfaceSignalEmitter> logger)
{
_logger = logger;
}
public Task EmitAsync(
string scanId,
IDictionary<string, object> signals,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
ArgumentNullException.ThrowIfNull(signals);
_logger.LogInformation(
"Emitting {SignalCount} surface signals for scan {ScanId}",
signals.Count, scanId);
foreach (var (key, value) in signals)
{
_logger.LogDebug("Signal {Key}: {Value}", key, value);
}
// In production, this would emit to message bus or policy engine
return Task.CompletedTask;
}
/// <summary>Builds signals from surface analysis result.</summary>
public static IDictionary<string, object> BuildSignals(SurfaceAnalysisResult result)
{
ArgumentNullException.ThrowIfNull(result);
var signals = new Dictionary<string, object>
{
[SurfaceSignalKeys.TotalSurfaceArea] = result.Summary.TotalEntries,
[SurfaceSignalKeys.RiskScore] = result.Summary.RiskScore,
[SurfaceSignalKeys.EntryPointCount] = result.EntryPoints.Count
};
// Add type-specific counts
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,
_ => $"surface.{type.ToString().ToLowerInvariant()}"
};
signals[key] = count;
}
return signals;
}
}