Add unit tests for VexLens normalizer, CPE parser, product mapper, and PURL parser

- Implemented comprehensive tests for VexLensNormalizer including format detection and normalization scenarios.
- Added tests for CpeParser covering CPE 2.3 and 2.2 formats, invalid inputs, and canonical key generation.
- Created tests for ProductMapper to validate parsing and matching logic across different strictness levels.
- Developed tests for PurlParser to ensure correct parsing of various PURL formats and validation of identifiers.
- Introduced stubs for Monaco editor and worker to facilitate testing in the web application.
- Updated project file for the test project to include necessary dependencies.
This commit is contained in:
StellaOps Bot
2025-12-06 16:28:12 +02:00
parent 2b892ad1b2
commit efd6850c38
132 changed files with 16675 additions and 5428 deletions

View File

@@ -38,6 +38,12 @@ public sealed class BunLanguageAnalyzer : ILanguageAnalyzer
continue;
}
// Parse workspace info for direct dependency detection
var workspaceInfo = BunWorkspaceHelper.ParseWorkspaceInfo(projectRoot);
// Parse bunfig.toml for custom registry info
var bunConfig = BunConfigHelper.ParseConfig(projectRoot);
// Stage 3: Collect packages based on classification
IReadOnlyList<BunPackage> packages;
if (classification.Kind == BunInputKind.InstalledModules)
@@ -61,6 +67,35 @@ public sealed class BunLanguageAnalyzer : ILanguageAnalyzer
continue;
}
// Mark direct, patched dependencies and custom registries
foreach (var package in packages)
{
package.IsDirect = workspaceInfo.DirectDependencies.ContainsKey(package.Name);
if (workspaceInfo.PatchedDependencies.TryGetValue(package.Name, out var patchFile))
{
package.IsPatched = true;
package.PatchFile = patchFile;
}
// Check for custom registry (scoped or default)
if (bunConfig.HasCustomRegistry)
{
// Check scoped registry first (e.g., @company/pkg uses company's registry)
if (package.Name.StartsWith('@'))
{
var scope = package.Name.Split('/')[0];
if (bunConfig.ScopeRegistries.TryGetValue(scope, out var scopeRegistry))
{
package.CustomRegistry = scopeRegistry;
}
}
// Fall back to default custom registry if no scope match
package.CustomRegistry ??= bunConfig.DefaultRegistry;
}
}
// Stage 4: Normalize and emit
var normalized = BunPackageNormalizer.Normalize(packages);
foreach (var package in normalized.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))

View File

@@ -0,0 +1,166 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
/// <summary>
/// Helper for parsing bunfig.toml configuration files.
/// Provides registry and scope information for dependency source tracking.
/// </summary>
internal static partial class BunConfigHelper
{
/// <summary>
/// Configuration information from bunfig.toml.
/// </summary>
public sealed record BunConfig
{
public static readonly BunConfig Empty = new(
null,
ImmutableDictionary<string, string>.Empty);
public BunConfig(
string? defaultRegistry,
IReadOnlyDictionary<string, string> scopeRegistries)
{
DefaultRegistry = defaultRegistry;
ScopeRegistries = scopeRegistries;
}
/// <summary>
/// Default registry URL for packages (from install.registry).
/// </summary>
public string? DefaultRegistry { get; }
/// <summary>
/// Scoped registries mapping scope name to registry URL.
/// </summary>
public IReadOnlyDictionary<string, string> ScopeRegistries { get; }
/// <summary>
/// Returns true if any custom registry configuration exists.
/// </summary>
public bool HasCustomRegistry => DefaultRegistry is not null || ScopeRegistries.Count > 0;
}
/// <summary>
/// Parses bunfig.toml from the project root.
/// </summary>
public static BunConfig ParseConfig(string projectRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectRoot);
var bunfigPath = Path.Combine(projectRoot, "bunfig.toml");
if (!File.Exists(bunfigPath))
{
return BunConfig.Empty;
}
try
{
var content = File.ReadAllText(bunfigPath);
return ParseToml(content);
}
catch (IOException)
{
return BunConfig.Empty;
}
}
/// <summary>
/// Simple TOML parser for bunfig.toml registry configuration.
/// Extracts [install] registry and [install.scopes] sections.
/// </summary>
private static BunConfig ParseToml(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return BunConfig.Empty;
}
string? defaultRegistry = null;
var scopeRegistries = new Dictionary<string, string>(StringComparer.Ordinal);
var lines = content.Split('\n');
var currentSection = string.Empty;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
// Skip comments and empty lines
if (string.IsNullOrEmpty(line) || line.StartsWith('#'))
{
continue;
}
// Section header
if (line.StartsWith('[') && line.EndsWith(']'))
{
currentSection = line[1..^1].Trim();
continue;
}
// Key-value pair
var equalsIndex = line.IndexOf('=');
if (equalsIndex > 0)
{
var key = line[..equalsIndex].Trim();
var value = line[(equalsIndex + 1)..].Trim();
// Remove quotes from value
value = StripQuotes(value);
// [install] registry = "..."
if (currentSection.Equals("install", StringComparison.OrdinalIgnoreCase) &&
key.Equals("registry", StringComparison.OrdinalIgnoreCase))
{
defaultRegistry = value;
}
// [install.scopes] "@scope" = { url = "..." } or "@scope" = "..."
else if (currentSection.Equals("install.scopes", StringComparison.OrdinalIgnoreCase))
{
var scopeName = StripQuotes(key);
var registryUrl = ExtractRegistryUrl(value);
if (!string.IsNullOrEmpty(scopeName) && !string.IsNullOrEmpty(registryUrl))
{
scopeRegistries[scopeName] = registryUrl;
}
}
}
}
return new BunConfig(
defaultRegistry,
scopeRegistries.ToImmutableDictionary(StringComparer.Ordinal));
}
private static string StripQuotes(string value)
{
if (value.Length >= 2)
{
if ((value.StartsWith('"') && value.EndsWith('"')) ||
(value.StartsWith('\'') && value.EndsWith('\'')))
{
return value[1..^1];
}
}
return value;
}
private static string? ExtractRegistryUrl(string value)
{
// Simple case: just a URL string
if (value.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
return value;
}
// Inline table: { url = "..." }
var urlMatch = UrlPattern().Match(value);
return urlMatch.Success ? urlMatch.Groups[1].Value : null;
}
[GeneratedRegex(@"url\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase)]
private static partial Regex UrlPattern();
}

View File

@@ -27,6 +27,48 @@ internal sealed class BunPackage
public string? Source { get; private init; }
public bool IsPrivate { get; private init; }
public bool IsDev { get; private init; }
public bool IsOptional { get; private init; }
public bool IsPeer { get; private init; }
/// <summary>
/// Source type: npm, git, tarball, file, link, workspace.
/// </summary>
public string SourceType { get; private init; } = "npm";
/// <summary>
/// Git commit hash for git dependencies.
/// </summary>
public string? GitCommit { get; private init; }
/// <summary>
/// Original specifier (e.g., "github:user/repo#tag").
/// </summary>
public string? Specifier { get; private init; }
/// <summary>
/// Direct dependencies of this package (for transitive analysis).
/// </summary>
public IReadOnlyList<string> Dependencies { get; private init; } = Array.Empty<string>();
/// <summary>
/// Whether this is a direct dependency (in root package.json) or transitive.
/// </summary>
public bool IsDirect { get; set; }
/// <summary>
/// Whether this package has been patched (via patchedDependencies or .patches directory).
/// </summary>
public bool IsPatched { get; set; }
/// <summary>
/// Path to the patch file if this package is patched.
/// </summary>
public string? PatchFile { get; set; }
/// <summary>
/// Custom registry URL if this package comes from a non-default registry.
/// </summary>
public string? CustomRegistry { get; set; }
/// <summary>
/// Logical path where this package was found (may be symlink).
@@ -67,7 +109,13 @@ internal sealed class BunPackage
Source = "node_modules",
Resolved = lockEntry?.Resolved,
Integrity = lockEntry?.Integrity,
IsDev = lockEntry?.IsDev ?? false
IsDev = lockEntry?.IsDev ?? false,
IsOptional = lockEntry?.IsOptional ?? false,
IsPeer = lockEntry?.IsPeer ?? false,
SourceType = lockEntry?.SourceType ?? "npm",
GitCommit = lockEntry?.GitCommit,
Specifier = lockEntry?.Specifier,
Dependencies = lockEntry?.Dependencies ?? Array.Empty<string>()
};
}
@@ -80,7 +128,13 @@ internal sealed class BunPackage
Source = source,
Resolved = entry.Resolved,
Integrity = entry.Integrity,
IsDev = entry.IsDev
IsDev = entry.IsDev,
IsOptional = entry.IsOptional,
IsPeer = entry.IsPeer,
SourceType = entry.SourceType,
GitCommit = entry.GitCommit,
Specifier = entry.Specifier,
Dependencies = entry.Dependencies
};
}
@@ -118,13 +172,58 @@ internal sealed class BunPackage
metadata["private"] = "true";
}
if (!string.IsNullOrEmpty(CustomRegistry))
{
metadata["customRegistry"] = CustomRegistry;
}
if (IsDev)
{
metadata["dev"] = "true";
}
if (IsDirect)
{
metadata["direct"] = "true";
}
if (!string.IsNullOrEmpty(GitCommit))
{
metadata["gitCommit"] = GitCommit;
}
if (IsOptional)
{
metadata["optional"] = "true";
}
metadata["packageManager"] = "bun";
if (IsPatched)
{
metadata["patched"] = "true";
}
if (!string.IsNullOrEmpty(PatchFile))
{
metadata["patchFile"] = NormalizePath(PatchFile);
}
if (IsPeer)
{
metadata["peer"] = "true";
}
if (SourceType != "npm")
{
metadata["sourceType"] = SourceType;
}
if (!string.IsNullOrEmpty(Specifier))
{
metadata["specifier"] = Specifier;
}
if (_occurrencePaths.Count > 1)
{
metadata["occurrences"] = string.Join(";", _occurrencePaths.Select(NormalizePath).Order(StringComparer.Ordinal));

View File

@@ -0,0 +1,414 @@
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
/// <summary>
/// Helper for parsing workspace configuration and direct dependencies from package.json files.
/// </summary>
internal static class BunWorkspaceHelper
{
/// <summary>
/// Information about workspaces and direct dependencies in a Bun project.
/// </summary>
public sealed record WorkspaceInfo
{
public static readonly WorkspaceInfo Empty = new(
ImmutableHashSet<string>.Empty,
ImmutableHashSet<string>.Empty,
ImmutableDictionary<string, DependencyType>.Empty,
ImmutableDictionary<string, string>.Empty);
public WorkspaceInfo(
IReadOnlySet<string> workspacePatterns,
IReadOnlySet<string> workspacePaths,
IReadOnlyDictionary<string, DependencyType> directDependencies,
IReadOnlyDictionary<string, string> patchedDependencies)
{
WorkspacePatterns = workspacePatterns;
WorkspacePaths = workspacePaths;
DirectDependencies = directDependencies;
PatchedDependencies = patchedDependencies;
}
/// <summary>
/// Glob patterns for workspace members from root package.json.
/// </summary>
public IReadOnlySet<string> WorkspacePatterns { get; }
/// <summary>
/// Resolved paths to workspace member directories.
/// </summary>
public IReadOnlySet<string> WorkspacePaths { get; }
/// <summary>
/// Direct dependencies declared in root and workspace package.json files.
/// Key is package name, value is dependency type.
/// </summary>
public IReadOnlyDictionary<string, DependencyType> DirectDependencies { get; }
/// <summary>
/// Patched dependencies. Key is package name (or name@version), value is patch file path.
/// </summary>
public IReadOnlyDictionary<string, string> PatchedDependencies { get; }
}
[Flags]
public enum DependencyType
{
None = 0,
Production = 1,
Dev = 2,
Optional = 4,
Peer = 8
}
/// <summary>
/// Parses workspace configuration and direct dependencies from project root.
/// </summary>
public static WorkspaceInfo ParseWorkspaceInfo(string projectRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(projectRoot);
var rootPackageJsonPath = Path.Combine(projectRoot, "package.json");
if (!File.Exists(rootPackageJsonPath))
{
return WorkspaceInfo.Empty;
}
try
{
var content = File.ReadAllText(rootPackageJsonPath);
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
// Parse workspace patterns
var workspacePatterns = ParseWorkspacePatterns(root);
// Resolve workspace paths
var workspacePaths = ResolveWorkspacePaths(projectRoot, workspacePatterns);
// Parse direct dependencies from root
var directDependencies = new Dictionary<string, DependencyType>(StringComparer.Ordinal);
ParseDependencies(root, directDependencies);
// Parse direct dependencies from each workspace
foreach (var wsPath in workspacePaths)
{
var wsPackageJsonPath = Path.Combine(projectRoot, wsPath, "package.json");
if (File.Exists(wsPackageJsonPath))
{
try
{
var wsContent = File.ReadAllText(wsPackageJsonPath);
using var wsDocument = JsonDocument.Parse(wsContent);
ParseDependencies(wsDocument.RootElement, directDependencies);
}
catch (JsonException)
{
// Skip malformed workspace package.json
}
}
}
// Parse patched dependencies
var patchedDependencies = ParsePatchedDependencies(root, projectRoot);
return new WorkspaceInfo(
workspacePatterns.ToImmutableHashSet(StringComparer.Ordinal),
workspacePaths.ToImmutableHashSet(StringComparer.Ordinal),
directDependencies.ToImmutableDictionary(StringComparer.Ordinal),
patchedDependencies.ToImmutableDictionary(StringComparer.Ordinal));
}
catch (JsonException)
{
return WorkspaceInfo.Empty;
}
catch (IOException)
{
return WorkspaceInfo.Empty;
}
}
/// <summary>
/// Checks if a package name is a direct dependency.
/// </summary>
public static bool IsDirect(string packageName, IReadOnlyDictionary<string, DependencyType> directDependencies)
{
return directDependencies.ContainsKey(packageName);
}
private static HashSet<string> ParseWorkspacePatterns(JsonElement root)
{
var patterns = new HashSet<string>(StringComparer.Ordinal);
if (!root.TryGetProperty("workspaces", out var workspaces))
{
return patterns;
}
// workspaces can be an array of patterns
if (workspaces.ValueKind == JsonValueKind.Array)
{
foreach (var pattern in workspaces.EnumerateArray())
{
var patternStr = pattern.GetString();
if (!string.IsNullOrWhiteSpace(patternStr))
{
patterns.Add(patternStr);
}
}
}
// Or an object with "packages" array (npm/yarn format)
else if (workspaces.ValueKind == JsonValueKind.Object &&
workspaces.TryGetProperty("packages", out var packages) &&
packages.ValueKind == JsonValueKind.Array)
{
foreach (var pattern in packages.EnumerateArray())
{
var patternStr = pattern.GetString();
if (!string.IsNullOrWhiteSpace(patternStr))
{
patterns.Add(patternStr);
}
}
}
return patterns;
}
private static HashSet<string> ResolveWorkspacePaths(string projectRoot, IEnumerable<string> patterns)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pattern in patterns)
{
// Handle glob patterns like "packages/*" or "apps/**"
if (pattern.Contains('*'))
{
var resolvedPaths = ExpandGlobPattern(projectRoot, pattern);
foreach (var path in resolvedPaths)
{
paths.Add(path);
}
}
else
{
// Direct path
var fullPath = Path.Combine(projectRoot, pattern);
if (Directory.Exists(fullPath) && File.Exists(Path.Combine(fullPath, "package.json")))
{
paths.Add(pattern);
}
}
}
return paths;
}
private static IEnumerable<string> ExpandGlobPattern(string projectRoot, string pattern)
{
// Simple glob expansion for common patterns
// Handles: "packages/*", "apps/*", "libs/**", etc.
var parts = pattern.Split('/', '\\');
var baseParts = new List<string>();
var hasGlob = false;
foreach (var part in parts)
{
if (part.Contains('*'))
{
hasGlob = true;
break;
}
baseParts.Add(part);
}
var baseDir = baseParts.Count > 0
? Path.Combine(projectRoot, string.Join(Path.DirectorySeparatorChar.ToString(), baseParts))
: projectRoot;
if (!Directory.Exists(baseDir))
{
yield break;
}
// For simple patterns like "packages/*", enumerate immediate subdirectories
if (hasGlob)
{
var isRecursive = pattern.Contains("**");
foreach (var dir in Directory.EnumerateDirectories(baseDir))
{
var dirPath = Path.Combine(string.Join("/", baseParts), Path.GetFileName(dir));
// Check if this is a package (has package.json)
if (File.Exists(Path.Combine(dir, "package.json")))
{
yield return dirPath;
}
// For recursive patterns, search subdirectories
if (isRecursive)
{
foreach (var subResult in EnumeratePackagesRecursively(dir, dirPath))
{
yield return subResult;
}
}
}
}
}
private static List<string> EnumeratePackagesRecursively(string directory, string relativePath)
{
var results = new List<string>();
try
{
foreach (var subdir in Directory.EnumerateDirectories(directory))
{
var subdirName = Path.GetFileName(subdir);
// Skip node_modules and hidden directories
if (subdirName == "node_modules" || subdirName.StartsWith('.'))
{
continue;
}
var subdirRelative = $"{relativePath}/{subdirName}";
if (File.Exists(Path.Combine(subdir, "package.json")))
{
results.Add(subdirRelative);
}
results.AddRange(EnumeratePackagesRecursively(subdir, subdirRelative));
}
}
catch (UnauthorizedAccessException)
{
// Skip inaccessible directories
}
return results;
}
private static void ParseDependencies(JsonElement root, Dictionary<string, DependencyType> result)
{
AddDependencies(root, "dependencies", DependencyType.Production, result);
AddDependencies(root, "devDependencies", DependencyType.Dev, result);
AddDependencies(root, "optionalDependencies", DependencyType.Optional, result);
AddDependencies(root, "peerDependencies", DependencyType.Peer, result);
}
private static Dictionary<string, string> ParsePatchedDependencies(JsonElement root, string projectRoot)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
// Check for patchedDependencies in package.json (Bun/pnpm style)
// Format: { "patchedDependencies": { "package-name@version": "patches/package-name@version.patch" } }
if (root.TryGetProperty("patchedDependencies", out var patchedDeps) &&
patchedDeps.ValueKind == JsonValueKind.Object)
{
foreach (var entry in patchedDeps.EnumerateObject())
{
var patchFile = entry.Value.GetString();
if (!string.IsNullOrEmpty(patchFile))
{
// Parse package name from key (could be "pkg@version" or just "pkg")
var packageName = ExtractPackageName(entry.Name);
result[packageName] = patchFile;
}
}
}
// Also check for patches directory
var patchesDir = Path.Combine(projectRoot, "patches");
if (Directory.Exists(patchesDir))
{
ScanPatchesDirectory(patchesDir, result);
}
// Bun uses .patches directory
var bunPatchesDir = Path.Combine(projectRoot, ".patches");
if (Directory.Exists(bunPatchesDir))
{
ScanPatchesDirectory(bunPatchesDir, result);
}
return result;
}
private static void ScanPatchesDirectory(string patchesDir, Dictionary<string, string> result)
{
try
{
foreach (var patchFile in Directory.EnumerateFiles(patchesDir, "*.patch"))
{
// Patch file name format: package-name@version.patch
var fileName = Path.GetFileNameWithoutExtension(patchFile);
var packageName = ExtractPackageName(fileName);
if (!string.IsNullOrEmpty(packageName) && !result.ContainsKey(packageName))
{
result[packageName] = patchFile;
}
}
}
catch (UnauthorizedAccessException)
{
// Skip inaccessible directory
}
}
private static string ExtractPackageName(string nameWithVersion)
{
// Format: package-name@version or @scope/package-name@version
if (string.IsNullOrEmpty(nameWithVersion))
{
return string.Empty;
}
// For scoped packages, find @ after the scope
if (nameWithVersion.StartsWith('@'))
{
var slashIndex = nameWithVersion.IndexOf('/');
if (slashIndex > 0)
{
var atIndex = nameWithVersion.IndexOf('@', slashIndex);
return atIndex > slashIndex ? nameWithVersion[..atIndex] : nameWithVersion;
}
}
// For regular packages
var lastAtIndex = nameWithVersion.LastIndexOf('@');
return lastAtIndex > 0 ? nameWithVersion[..lastAtIndex] : nameWithVersion;
}
private static void AddDependencies(
JsonElement root,
string propertyName,
DependencyType type,
Dictionary<string, DependencyType> result)
{
if (!root.TryGetProperty(propertyName, out var deps) ||
deps.ValueKind != JsonValueKind.Object)
{
return;
}
foreach (var dep in deps.EnumerateObject())
{
var name = dep.Name;
if (result.TryGetValue(name, out var existingType))
{
result[name] = existingType | type;
}
else
{
result[name] = type;
}
}
}
}

View File

@@ -0,0 +1,373 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Parses go.mod files to extract module dependencies.
/// Supports module declarations, require blocks, replace directives, and indirect markers.
/// </summary>
internal static partial class GoModParser
{
/// <summary>
/// Parsed go.mod file data.
/// </summary>
public sealed record GoModData
{
public static readonly GoModData Empty = new(
null,
null,
ImmutableArray<GoModRequire>.Empty,
ImmutableArray<GoModReplace>.Empty,
ImmutableArray<GoModExclude>.Empty,
ImmutableArray<string>.Empty);
public GoModData(
string? modulePath,
string? goVersion,
ImmutableArray<GoModRequire> requires,
ImmutableArray<GoModReplace> replaces,
ImmutableArray<GoModExclude> excludes,
ImmutableArray<string> retracts)
{
ModulePath = modulePath;
GoVersion = goVersion;
Requires = requires;
Replaces = replaces;
Excludes = excludes;
Retracts = retracts;
}
public string? ModulePath { get; }
public string? GoVersion { get; }
public ImmutableArray<GoModRequire> Requires { get; }
public ImmutableArray<GoModReplace> Replaces { get; }
public ImmutableArray<GoModExclude> Excludes { get; }
public ImmutableArray<string> Retracts { get; }
public bool IsEmpty => string.IsNullOrEmpty(ModulePath);
}
/// <summary>
/// A required dependency from go.mod.
/// </summary>
public sealed record GoModRequire(
string Path,
string Version,
bool IsIndirect);
/// <summary>
/// A replace directive from go.mod.
/// </summary>
public sealed record GoModReplace(
string OldPath,
string? OldVersion,
string NewPath,
string? NewVersion);
/// <summary>
/// An exclude directive from go.mod.
/// </summary>
public sealed record GoModExclude(
string Path,
string Version);
/// <summary>
/// Parses a go.mod file from the given path.
/// </summary>
public static GoModData Parse(string goModPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(goModPath);
if (!File.Exists(goModPath))
{
return GoModData.Empty;
}
try
{
var content = File.ReadAllText(goModPath);
return ParseContent(content);
}
catch (IOException)
{
return GoModData.Empty;
}
catch (UnauthorizedAccessException)
{
return GoModData.Empty;
}
}
/// <summary>
/// Parses go.mod content string.
/// </summary>
public static GoModData ParseContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoModData.Empty;
}
string? modulePath = null;
string? goVersion = null;
var requires = new List<GoModRequire>();
var replaces = new List<GoModReplace>();
var excludes = new List<GoModExclude>();
var retracts = new List<string>();
// Remove comments (but preserve // indirect markers)
var lines = content.Split('\n');
var inRequireBlock = false;
var inReplaceBlock = false;
var inExcludeBlock = false;
var inRetractBlock = false;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
// Skip empty lines and full-line comments
if (string.IsNullOrEmpty(line) || line.StartsWith("//"))
{
continue;
}
// Handle block endings
if (line == ")")
{
inRequireBlock = false;
inReplaceBlock = false;
inExcludeBlock = false;
inRetractBlock = false;
continue;
}
// Handle block starts
if (line == "require (")
{
inRequireBlock = true;
continue;
}
if (line == "replace (")
{
inReplaceBlock = true;
continue;
}
if (line == "exclude (")
{
inExcludeBlock = true;
continue;
}
if (line == "retract (")
{
inRetractBlock = true;
continue;
}
// Parse module directive
if (line.StartsWith("module ", StringComparison.Ordinal))
{
modulePath = ExtractQuotedOrUnquoted(line["module ".Length..]);
continue;
}
// Parse go directive
if (line.StartsWith("go ", StringComparison.Ordinal))
{
goVersion = line["go ".Length..].Trim();
continue;
}
// Parse single-line require
if (line.StartsWith("require ", StringComparison.Ordinal) && !line.Contains('('))
{
var req = ParseRequireLine(line["require ".Length..]);
if (req is not null)
{
requires.Add(req);
}
continue;
}
// Parse single-line replace
if (line.StartsWith("replace ", StringComparison.Ordinal) && !line.Contains('('))
{
var rep = ParseReplaceLine(line["replace ".Length..]);
if (rep is not null)
{
replaces.Add(rep);
}
continue;
}
// Parse single-line exclude
if (line.StartsWith("exclude ", StringComparison.Ordinal) && !line.Contains('('))
{
var exc = ParseExcludeLine(line["exclude ".Length..]);
if (exc is not null)
{
excludes.Add(exc);
}
continue;
}
// Parse single-line retract
if (line.StartsWith("retract ", StringComparison.Ordinal) && !line.Contains('('))
{
var version = line["retract ".Length..].Trim();
if (!string.IsNullOrEmpty(version))
{
retracts.Add(version);
}
continue;
}
// Handle block contents
if (inRequireBlock)
{
var req = ParseRequireLine(line);
if (req is not null)
{
requires.Add(req);
}
}
else if (inReplaceBlock)
{
var rep = ParseReplaceLine(line);
if (rep is not null)
{
replaces.Add(rep);
}
}
else if (inExcludeBlock)
{
var exc = ParseExcludeLine(line);
if (exc is not null)
{
excludes.Add(exc);
}
}
else if (inRetractBlock)
{
var version = StripComment(line).Trim();
if (!string.IsNullOrEmpty(version))
{
retracts.Add(version);
}
}
}
if (string.IsNullOrEmpty(modulePath))
{
return GoModData.Empty;
}
return new GoModData(
modulePath,
goVersion,
requires.ToImmutableArray(),
replaces.ToImmutableArray(),
excludes.ToImmutableArray(),
retracts.ToImmutableArray());
}
private static GoModRequire? ParseRequireLine(string line)
{
// Format: path version [// indirect]
var isIndirect = line.Contains("// indirect", StringComparison.OrdinalIgnoreCase);
line = StripComment(line);
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return null;
}
var path = parts[0].Trim();
var version = parts[1].Trim();
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(version))
{
return null;
}
return new GoModRequire(path, version, isIndirect);
}
private static GoModReplace? ParseReplaceLine(string line)
{
// Format: old [version] => new [version]
line = StripComment(line);
var arrowIndex = line.IndexOf("=>", StringComparison.Ordinal);
if (arrowIndex < 0)
{
return null;
}
var leftPart = line[..arrowIndex].Trim();
var rightPart = line[(arrowIndex + 2)..].Trim();
var leftParts = leftPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var rightParts = rightPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (leftParts.Length == 0 || rightParts.Length == 0)
{
return null;
}
var oldPath = leftParts[0];
var oldVersion = leftParts.Length > 1 ? leftParts[1] : null;
var newPath = rightParts[0];
var newVersion = rightParts.Length > 1 ? rightParts[1] : null;
return new GoModReplace(oldPath, oldVersion, newPath, newVersion);
}
private static GoModExclude? ParseExcludeLine(string line)
{
line = StripComment(line);
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
{
return null;
}
return new GoModExclude(parts[0], parts[1]);
}
private static string StripComment(string line)
{
var commentIndex = line.IndexOf("//", StringComparison.Ordinal);
return commentIndex >= 0 ? line[..commentIndex].Trim() : line.Trim();
}
private static string ExtractQuotedOrUnquoted(string value)
{
value = value.Trim();
// Remove quotes if present
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
return value[1..^1];
}
// Remove backticks if present
if (value.Length >= 2 && value[0] == '`' && value[^1] == '`')
{
return value[1..^1];
}
// Strip any trailing comment
return StripComment(value);
}
}

View File

@@ -0,0 +1,199 @@
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Detects private Go modules based on common patterns and heuristics.
/// Uses patterns similar to GOPRIVATE environment variable matching.
/// </summary>
internal static partial class GoPrivateModuleDetector
{
// Common private hosting patterns
private static readonly string[] PrivateHostPatterns =
[
// GitLab self-hosted (common pattern)
@"^gitlab\.[^/]+/",
// Gitea/Gogs self-hosted
@"^git\.[^/]+/",
@"^gitea\.[^/]+/",
@"^gogs\.[^/]+/",
// Bitbucket Server
@"^bitbucket\.[^/]+/",
@"^stash\.[^/]+/",
// Azure DevOps (not github.com, gitlab.com, etc.)
@"^dev\.azure\.com/",
@"^[^/]+\.visualstudio\.com/",
// AWS CodeCommit
@"^git-codecommit\.[^/]+\.amazonaws\.com/",
// Internal/corporate patterns
@"^internal\.[^/]+/",
@"^private\.[^/]+/",
@"^corp\.[^/]+/",
@"^code\.[^/]+/",
// IP addresses (likely internal)
@"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}[:/]",
// Localhost
@"^localhost[:/]",
@"^127\.0\.0\.1[:/]",
];
// Known public hosting services
private static readonly string[] PublicHosts =
[
"github.com",
"gitlab.com",
"bitbucket.org",
"golang.org",
"google.golang.org",
"gopkg.in",
"go.uber.org",
"go.etcd.io",
"k8s.io",
"sigs.k8s.io",
"cloud.google.com",
"google.cloud.go",
];
private static readonly Regex[] CompiledPatterns;
static GoPrivateModuleDetector()
{
CompiledPatterns = PrivateHostPatterns
.Select(pattern => new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase))
.ToArray();
}
/// <summary>
/// Determines if a module path appears to be from a private source.
/// </summary>
public static bool IsLikelyPrivate(string modulePath)
{
if (string.IsNullOrWhiteSpace(modulePath))
{
return false;
}
// Check if it's a known public host first
foreach (var publicHost in PublicHosts)
{
if (modulePath.StartsWith(publicHost, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
// Check against private patterns
foreach (var pattern in CompiledPatterns)
{
if (pattern.IsMatch(modulePath))
{
return true;
}
}
// Check for internal TLDs
var host = ExtractHost(modulePath);
if (IsInternalTld(host))
{
return true;
}
return false;
}
/// <summary>
/// Gets the category of a module (public, private, local).
/// </summary>
public static string GetModuleCategory(string modulePath)
{
if (string.IsNullOrWhiteSpace(modulePath))
{
return "unknown";
}
// Local replacements start with . or /
if (modulePath.StartsWith('.') || modulePath.StartsWith('/') || modulePath.StartsWith('\\'))
{
return "local";
}
// Windows absolute paths
if (modulePath.Length >= 2 && char.IsLetter(modulePath[0]) && modulePath[1] == ':')
{
return "local";
}
if (IsLikelyPrivate(modulePath))
{
return "private";
}
return "public";
}
/// <summary>
/// Extracts the registry/host from a module path.
/// </summary>
public static string? GetRegistry(string modulePath)
{
if (string.IsNullOrWhiteSpace(modulePath))
{
return null;
}
// Local paths don't have a registry
if (modulePath.StartsWith('.') || modulePath.StartsWith('/') || modulePath.StartsWith('\\'))
{
return null;
}
var host = ExtractHost(modulePath);
if (string.IsNullOrEmpty(host))
{
return null;
}
// Standard Go proxy for public modules
if (!IsLikelyPrivate(modulePath))
{
return "proxy.golang.org";
}
// Private modules use direct access
return host;
}
private static string ExtractHost(string modulePath)
{
// Module path format: host/path
var slashIndex = modulePath.IndexOf('/');
return slashIndex > 0 ? modulePath[..slashIndex] : modulePath;
}
private static bool IsInternalTld(string host)
{
if (string.IsNullOrEmpty(host))
{
return false;
}
// Internal/non-public TLDs
string[] internalTlds = [".local", ".internal", ".corp", ".lan", ".intranet", ".private"];
foreach (var tld in internalTlds)
{
if (host.EndsWith(tld, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
// No TLD at all (single-word hostname)
if (!host.Contains('.'))
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,185 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Discovers Go project roots by looking for go.mod, go.work, and vendor directories.
/// </summary>
internal static class GoProjectDiscoverer
{
/// <summary>
/// Discovered Go project information.
/// </summary>
public sealed record GoProject
{
public GoProject(
string rootPath,
string? goModPath,
string? goSumPath,
string? goWorkPath,
string? vendorModulesPath,
ImmutableArray<string> workspaceMembers)
{
RootPath = rootPath;
GoModPath = goModPath;
GoSumPath = goSumPath;
GoWorkPath = goWorkPath;
VendorModulesPath = vendorModulesPath;
WorkspaceMembers = workspaceMembers;
}
public string RootPath { get; }
public string? GoModPath { get; }
public string? GoSumPath { get; }
public string? GoWorkPath { get; }
public string? VendorModulesPath { get; }
public ImmutableArray<string> WorkspaceMembers { get; }
public bool HasGoMod => GoModPath is not null;
public bool HasGoSum => GoSumPath is not null;
public bool HasGoWork => GoWorkPath is not null;
public bool HasVendor => VendorModulesPath is not null;
public bool IsWorkspace => HasGoWork && WorkspaceMembers.Length > 0;
}
/// <summary>
/// Discovers all Go projects under the given root path.
/// </summary>
public static IReadOnlyList<GoProject> Discover(string rootPath, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
if (!Directory.Exists(rootPath))
{
return Array.Empty<GoProject>();
}
var projects = new List<GoProject>();
var visitedRoots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// First, check for go.work (workspace) at root
var goWorkPath = Path.Combine(rootPath, "go.work");
if (File.Exists(goWorkPath))
{
var workspaceProject = DiscoverWorkspace(rootPath, goWorkPath, cancellationToken);
if (workspaceProject is not null)
{
projects.Add(workspaceProject);
visitedRoots.Add(rootPath);
// Mark all workspace members as visited
foreach (var member in workspaceProject.WorkspaceMembers)
{
var memberFullPath = Path.GetFullPath(Path.Combine(rootPath, member));
visitedRoots.Add(memberFullPath);
}
}
}
// Then scan for standalone go.mod files
try
{
var enumeration = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 10
};
foreach (var goModFile in Directory.EnumerateFiles(rootPath, "go.mod", enumeration))
{
cancellationToken.ThrowIfCancellationRequested();
var projectDir = Path.GetDirectoryName(goModFile);
if (string.IsNullOrEmpty(projectDir))
{
continue;
}
// Skip if already part of a workspace
var normalizedDir = Path.GetFullPath(projectDir);
if (visitedRoots.Contains(normalizedDir))
{
continue;
}
// Skip vendor directories
if (projectDir.Contains($"{Path.DirectorySeparatorChar}vendor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) ||
projectDir.EndsWith($"{Path.DirectorySeparatorChar}vendor", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var project = DiscoverStandaloneProject(projectDir);
if (project is not null)
{
projects.Add(project);
visitedRoots.Add(normalizedDir);
}
}
}
catch (UnauthorizedAccessException)
{
// Skip inaccessible directories
}
return projects;
}
private static GoProject? DiscoverWorkspace(string rootPath, string goWorkPath, CancellationToken cancellationToken)
{
var workData = GoWorkParser.Parse(goWorkPath);
if (workData.IsEmpty)
{
return null;
}
var workspaceMembers = new List<string>();
foreach (var usePath in workData.UsePaths)
{
cancellationToken.ThrowIfCancellationRequested();
var memberPath = Path.Combine(rootPath, usePath);
var memberGoMod = Path.Combine(memberPath, "go.mod");
if (Directory.Exists(memberPath) && File.Exists(memberGoMod))
{
workspaceMembers.Add(usePath);
}
}
// The workspace itself may have a go.mod or not
var rootGoMod = Path.Combine(rootPath, "go.mod");
var rootGoSum = Path.Combine(rootPath, "go.sum");
var vendorModules = Path.Combine(rootPath, "vendor", "modules.txt");
return new GoProject(
rootPath,
File.Exists(rootGoMod) ? rootGoMod : null,
File.Exists(rootGoSum) ? rootGoSum : null,
goWorkPath,
File.Exists(vendorModules) ? vendorModules : null,
workspaceMembers.ToImmutableArray());
}
private static GoProject? DiscoverStandaloneProject(string projectDir)
{
var goModPath = Path.Combine(projectDir, "go.mod");
if (!File.Exists(goModPath))
{
return null;
}
var goSumPath = Path.Combine(projectDir, "go.sum");
var vendorModulesPath = Path.Combine(projectDir, "vendor", "modules.txt");
return new GoProject(
projectDir,
goModPath,
File.Exists(goSumPath) ? goSumPath : null,
null,
File.Exists(vendorModulesPath) ? vendorModulesPath : null,
ImmutableArray<string>.Empty);
}
}

View File

@@ -0,0 +1,129 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Parses go.sum files to extract module checksums.
/// Format: module version hash
/// Example: github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
/// </summary>
internal static class GoSumParser
{
/// <summary>
/// A single entry from go.sum.
/// </summary>
public sealed record GoSumEntry(
string Path,
string Version,
string Hash,
bool IsGoMod);
/// <summary>
/// Parsed go.sum data.
/// </summary>
public sealed record GoSumData
{
public static readonly GoSumData Empty = new(ImmutableDictionary<string, GoSumEntry>.Empty);
public GoSumData(ImmutableDictionary<string, GoSumEntry> entries)
{
Entries = entries;
}
/// <summary>
/// Entries keyed by "path@version" for quick lookup.
/// </summary>
public ImmutableDictionary<string, GoSumEntry> Entries { get; }
public bool IsEmpty => Entries.Count == 0;
/// <summary>
/// Tries to find the checksum for a module.
/// </summary>
public string? GetHash(string path, string version)
{
var key = $"{path}@{version}";
return Entries.TryGetValue(key, out var entry) ? entry.Hash : null;
}
}
/// <summary>
/// Parses a go.sum file from the given path.
/// </summary>
public static GoSumData Parse(string goSumPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(goSumPath);
if (!File.Exists(goSumPath))
{
return GoSumData.Empty;
}
try
{
var content = File.ReadAllText(goSumPath);
return ParseContent(content);
}
catch (IOException)
{
return GoSumData.Empty;
}
catch (UnauthorizedAccessException)
{
return GoSumData.Empty;
}
}
/// <summary>
/// Parses go.sum content string.
/// </summary>
public static GoSumData ParseContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoSumData.Empty;
}
var entries = new Dictionary<string, GoSumEntry>(StringComparer.Ordinal);
var lines = content.Split('\n');
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line))
{
continue;
}
// Format: module version[/go.mod] hash
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 3)
{
continue;
}
var path = parts[0];
var versionPart = parts[1];
var hash = parts[2];
// Check if this is a go.mod checksum (version ends with /go.mod)
var isGoMod = versionPart.EndsWith("/go.mod", StringComparison.Ordinal);
var version = isGoMod ? versionPart[..^"/go.mod".Length] : versionPart;
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(version) || string.IsNullOrEmpty(hash))
{
continue;
}
// Prefer the module hash over the go.mod hash
var key = $"{path}@{version}";
if (!isGoMod || !entries.ContainsKey(key))
{
entries[key] = new GoSumEntry(path, version, hash, isGoMod);
}
}
return new GoSumData(entries.ToImmutableDictionary(StringComparer.Ordinal));
}
}

View File

@@ -0,0 +1,178 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Parses vendor/modules.txt files to extract vendored dependencies.
/// Format:
/// # github.com/pkg/errors v0.9.1
/// ## explicit
/// github.com/pkg/errors
/// # golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a
/// ## explicit; go 1.17
/// golang.org/x/sys/unix
/// </summary>
internal static class GoVendorParser
{
/// <summary>
/// A vendored module entry.
/// </summary>
public sealed record GoVendorModule(
string Path,
string Version,
bool IsExplicit,
string? GoVersion,
ImmutableArray<string> Packages);
/// <summary>
/// Parsed vendor/modules.txt data.
/// </summary>
public sealed record GoVendorData
{
public static readonly GoVendorData Empty = new(ImmutableArray<GoVendorModule>.Empty);
public GoVendorData(ImmutableArray<GoVendorModule> modules)
{
Modules = modules;
}
public ImmutableArray<GoVendorModule> Modules { get; }
public bool IsEmpty => Modules.IsEmpty;
/// <summary>
/// Checks if a module path is vendored.
/// </summary>
public bool IsVendored(string path)
{
return Modules.Any(m => string.Equals(m.Path, path, StringComparison.Ordinal));
}
}
/// <summary>
/// Parses a vendor/modules.txt file from the given path.
/// </summary>
public static GoVendorData Parse(string modulesPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(modulesPath);
if (!File.Exists(modulesPath))
{
return GoVendorData.Empty;
}
try
{
var content = File.ReadAllText(modulesPath);
return ParseContent(content);
}
catch (IOException)
{
return GoVendorData.Empty;
}
catch (UnauthorizedAccessException)
{
return GoVendorData.Empty;
}
}
/// <summary>
/// Parses vendor/modules.txt content string.
/// </summary>
public static GoVendorData ParseContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoVendorData.Empty;
}
var modules = new List<GoVendorModule>();
var lines = content.Split('\n');
string? currentPath = null;
string? currentVersion = null;
var currentPackages = new List<string>();
var isExplicit = false;
string? goVersion = null;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (string.IsNullOrEmpty(line))
{
continue;
}
// Module header: # module/path version
if (line.StartsWith("# ", StringComparison.Ordinal) && !line.StartsWith("## ", StringComparison.Ordinal))
{
// Save previous module if any
if (!string.IsNullOrEmpty(currentPath) && !string.IsNullOrEmpty(currentVersion))
{
modules.Add(new GoVendorModule(
currentPath,
currentVersion,
isExplicit,
goVersion,
currentPackages.ToImmutableArray()));
}
// Parse new module header
var parts = line[2..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
currentPath = parts[0];
currentVersion = parts[1];
currentPackages.Clear();
isExplicit = false;
goVersion = null;
}
else
{
currentPath = null;
currentVersion = null;
}
continue;
}
// Metadata line: ## explicit or ## explicit; go 1.17
if (line.StartsWith("## ", StringComparison.Ordinal))
{
var metadata = line[3..];
isExplicit = metadata.Contains("explicit", StringComparison.OrdinalIgnoreCase);
// Extract go version if present
var goIndex = metadata.IndexOf("go ", StringComparison.Ordinal);
if (goIndex >= 0)
{
var goVersionPart = metadata[(goIndex + 3)..].Trim();
var semicolonIndex = goVersionPart.IndexOf(';');
goVersion = semicolonIndex >= 0 ? goVersionPart[..semicolonIndex].Trim() : goVersionPart;
}
continue;
}
// Package path (not starting with #)
if (!line.StartsWith('#') && !string.IsNullOrEmpty(currentPath))
{
currentPackages.Add(line);
}
}
// Save last module
if (!string.IsNullOrEmpty(currentPath) && !string.IsNullOrEmpty(currentVersion))
{
modules.Add(new GoVendorModule(
currentPath,
currentVersion,
isExplicit,
goVersion,
currentPackages.ToImmutableArray()));
}
return new GoVendorData(modules.ToImmutableArray());
}
}

View File

@@ -0,0 +1,239 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Parses go.work files for Go workspace support (Go 1.18+).
/// Format:
/// go 1.21
/// use (
/// ./app
/// ./lib
/// )
/// replace example.com/old => example.com/new v1.0.0
/// </summary>
internal static class GoWorkParser
{
/// <summary>
/// Parsed go.work file data.
/// </summary>
public sealed record GoWorkData
{
public static readonly GoWorkData Empty = new(
null,
ImmutableArray<string>.Empty,
ImmutableArray<GoModParser.GoModReplace>.Empty);
public GoWorkData(
string? goVersion,
ImmutableArray<string> usePaths,
ImmutableArray<GoModParser.GoModReplace> replaces)
{
GoVersion = goVersion;
UsePaths = usePaths;
Replaces = replaces;
}
/// <summary>
/// Go version from the go directive.
/// </summary>
public string? GoVersion { get; }
/// <summary>
/// Relative paths to workspace member modules (from use directives).
/// </summary>
public ImmutableArray<string> UsePaths { get; }
/// <summary>
/// Replace directives that apply to all workspace modules.
/// </summary>
public ImmutableArray<GoModParser.GoModReplace> Replaces { get; }
public bool IsEmpty => UsePaths.IsEmpty;
}
/// <summary>
/// Parses a go.work file from the given path.
/// </summary>
public static GoWorkData Parse(string goWorkPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(goWorkPath);
if (!File.Exists(goWorkPath))
{
return GoWorkData.Empty;
}
try
{
var content = File.ReadAllText(goWorkPath);
return ParseContent(content);
}
catch (IOException)
{
return GoWorkData.Empty;
}
catch (UnauthorizedAccessException)
{
return GoWorkData.Empty;
}
}
/// <summary>
/// Parses go.work content string.
/// </summary>
public static GoWorkData ParseContent(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoWorkData.Empty;
}
string? goVersion = null;
var usePaths = new List<string>();
var replaces = new List<GoModParser.GoModReplace>();
var lines = content.Split('\n');
var inUseBlock = false;
var inReplaceBlock = false;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
// Skip empty lines and comments
if (string.IsNullOrEmpty(line) || line.StartsWith("//"))
{
continue;
}
// Handle block endings
if (line == ")")
{
inUseBlock = false;
inReplaceBlock = false;
continue;
}
// Handle block starts
if (line == "use (")
{
inUseBlock = true;
continue;
}
if (line == "replace (")
{
inReplaceBlock = true;
continue;
}
// Parse go directive
if (line.StartsWith("go ", StringComparison.Ordinal))
{
goVersion = line["go ".Length..].Trim();
continue;
}
// Parse single-line use
if (line.StartsWith("use ", StringComparison.Ordinal) && !line.Contains('('))
{
var path = ExtractPath(line["use ".Length..]);
if (!string.IsNullOrEmpty(path))
{
usePaths.Add(path);
}
continue;
}
// Parse single-line replace
if (line.StartsWith("replace ", StringComparison.Ordinal) && !line.Contains('('))
{
var rep = ParseReplaceLine(line["replace ".Length..]);
if (rep is not null)
{
replaces.Add(rep);
}
continue;
}
// Handle block contents
if (inUseBlock)
{
var path = ExtractPath(line);
if (!string.IsNullOrEmpty(path))
{
usePaths.Add(path);
}
}
else if (inReplaceBlock)
{
var rep = ParseReplaceLine(line);
if (rep is not null)
{
replaces.Add(rep);
}
}
}
return new GoWorkData(
goVersion,
usePaths.ToImmutableArray(),
replaces.ToImmutableArray());
}
private static string ExtractPath(string value)
{
value = StripComment(value).Trim();
// Remove quotes if present
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
return value[1..^1];
}
if (value.Length >= 2 && value[0] == '`' && value[^1] == '`')
{
return value[1..^1];
}
return value;
}
private static GoModParser.GoModReplace? ParseReplaceLine(string line)
{
line = StripComment(line);
var arrowIndex = line.IndexOf("=>", StringComparison.Ordinal);
if (arrowIndex < 0)
{
return null;
}
var leftPart = line[..arrowIndex].Trim();
var rightPart = line[(arrowIndex + 2)..].Trim();
var leftParts = leftPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var rightParts = rightPart.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (leftParts.Length == 0 || rightParts.Length == 0)
{
return null;
}
var oldPath = leftParts[0];
var oldVersion = leftParts.Length > 1 ? leftParts[1] : null;
var newPath = rightParts[0];
var newVersion = rightParts.Length > 1 ? rightParts[1] : null;
return new GoModParser.GoModReplace(oldPath, oldVersion, newPath, newVersion);
}
private static string StripComment(string line)
{
var commentIndex = line.IndexOf("//", StringComparison.Ordinal);
return commentIndex >= 0 ? line[..commentIndex].Trim() : line.Trim();
}
}

View File

@@ -0,0 +1,145 @@
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.
/// </summary>
public interface ISurfaceEntryCollector
{
/// <summary>
/// Unique identifier for this collector.
/// </summary>
string CollectorId { get; }
/// <summary>
/// Display name for this collector.
/// </summary>
string Name { get; }
/// <summary>
/// Languages supported by this collector.
/// </summary>
IReadOnlyList<string> SupportedLanguages { 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>
IAsyncEnumerable<SurfaceEntry> CollectAsync(
SurfaceCollectionContext context,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Context for surface entry collection.
/// </summary>
public sealed record SurfaceCollectionContext
{
/// <summary>
/// Scan identifier.
/// </summary>
public required string ScanId { get; init; }
/// <summary>
/// Root directory being scanned.
/// </summary>
public required string RootPath { get; init; }
/// <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();
}

View File

@@ -0,0 +1,187 @@
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.
/// </summary>
public interface ISurfaceEntryRegistry
{
/// <summary>
/// Registers a collector.
/// </summary>
void Register(ISurfaceEntryCollector collector);
/// <summary>
/// Gets all registered collectors.
/// </summary>
IReadOnlyList<ISurfaceEntryCollector> GetCollectors();
/// <summary>
/// Gets collectors that can analyze the given context.
/// </summary>
IReadOnlyList<ISurfaceEntryCollector> GetApplicableCollectors(SurfaceCollectionContext context);
/// <summary>
/// Collects entries using all applicable collectors.
/// </summary>
IAsyncEnumerable<SurfaceEntry> CollectAllAsync(
SurfaceCollectionContext context,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of surface entry registry.
/// </summary>
public sealed class SurfaceEntryRegistry : ISurfaceEntryRegistry
{
private readonly List<ISurfaceEntryCollector> _collectors = [];
private readonly ILogger<SurfaceEntryRegistry> _logger;
private readonly object _lock = new();
public SurfaceEntryRegistry(ILogger<SurfaceEntryRegistry> logger)
{
_logger = logger;
}
public void Register(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);
return;
}
_collectors.Add(collector);
_logger.LogDebug(
"Registered surface collector {CollectorId} ({Name}) for languages: {Languages}",
collector.CollectorId,
collector.Name,
string.Join(", ", collector.SupportedLanguages));
}
}
public IReadOnlyList<ISurfaceEntryCollector> GetCollectors()
{
lock (_lock)
{
return _collectors
.OrderByDescending(c => c.Priority)
.ToList();
}
}
public IReadOnlyList<ISurfaceEntryCollector> GetApplicableCollectors(SurfaceCollectionContext context)
{
ArgumentNullException.ThrowIfNull(context);
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;
}
}
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

@@ -0,0 +1,115 @@
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.).
/// </summary>
public sealed record EntryPoint
{
/// <summary>
/// Unique identifier for this entry point.
/// </summary>
public required string Id { get; init; }
/// <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>
/// 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>
/// Handler function/method name.
/// </summary>
public required string Handler { get; init; }
/// <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>
/// Handler parameters/arguments.
/// </summary>
public IReadOnlyList<string> Parameters { get; init; } = [];
/// <summary>
/// Middleware chain applied to this endpoint.
/// </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>
public sealed record EntryPointDiscoveryResult
{
/// <summary>
/// Scan identifier.
/// </summary>
public required string ScanId { get; init; }
/// <summary>
/// When discovery was performed.
/// </summary>
public required DateTimeOffset DiscoveredAt { get; init; }
/// <summary>
/// Discovered entry points.
/// </summary>
public required IReadOnlyList<EntryPoint> EntryPoints { get; init; }
/// <summary>
/// Frameworks detected.
/// </summary>
public required IReadOnlyList<string> DetectedFrameworks { 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; }
}

View File

@@ -0,0 +1,171 @@
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

@@ -0,0 +1,126 @@
using System.Security.Cryptography;
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>
public required string File { get; init; }
/// <summary>
/// Line number in the 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 source file.
/// </summary>
public string? FileHash { get; init; }
/// <summary>
/// Code snippet around the detection.
/// </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; }
}

View File

@@ -0,0 +1,58 @@
namespace StellaOps.Scanner.Surface.Models;
/// <summary>
/// Classification of attack surface entry types.
/// </summary>
public enum SurfaceType
{
/// <summary>Network-exposed endpoints, listeners, ports.</summary>
NetworkEndpoint,
/// <summary>File system operations, path access.</summary>
FileOperation,
/// <summary>Process/command execution, subprocess spawns.</summary>
ProcessExecution,
/// <summary>Cryptographic operations, key handling.</summary>
CryptoOperation,
/// <summary>Authentication entry points, session handling.</summary>
AuthenticationPoint,
/// <summary>User input handling, injection points.</summary>
InputHandling,
/// <summary>Secret/credential access points.</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>
/// Confidence level for surface entry detection.
/// </summary>
public enum ConfidenceLevel
{
/// <summary>Low confidence - heuristic or pattern match.</summary>
Low = 1,
/// <summary>Medium confidence - likely match.</summary>
Medium = 2,
/// <summary>High confidence - definite match.</summary>
High = 3,
/// <summary>Verified - confirmed through multiple signals.</summary>
Verified = 4
}

View File

@@ -0,0 +1,121 @@
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

@@ -0,0 +1,153 @@
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

@@ -0,0 +1,177 @@
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

@@ -0,0 +1,64 @@
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

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**" />
<EmbeddedResource Include="**\*.json" Exclude="obj\**;bin\**" />
<None Include="**\*" Exclude="**\*.cs;**\*.json;bin\**;obj\**" />
</ItemGroup>
<ItemGroup>
<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" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,101 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Surface.Discovery;
using StellaOps.Scanner.Surface.Models;
using StellaOps.Scanner.Surface.Output;
using StellaOps.Scanner.Surface.Signals;
namespace StellaOps.Scanner.Surface;
/// <summary>
/// Main interface for surface analysis operations.
/// </summary>
public interface ISurfaceAnalyzer
{
/// <summary>
/// Performs surface analysis on the given context.
/// </summary>
Task<SurfaceAnalysisResult> AnalyzeAsync(
SurfaceCollectionContext context,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of surface analyzer.
/// Coordinates collectors, signal emission, and output writing.
/// </summary>
public sealed class SurfaceAnalyzer : ISurfaceAnalyzer
{
private readonly ISurfaceEntryRegistry _registry;
private readonly ISurfaceSignalEmitter _signalEmitter;
private readonly ISurfaceAnalysisWriter _writer;
private readonly ILogger<SurfaceAnalyzer> _logger;
public SurfaceAnalyzer(
ISurfaceEntryRegistry registry,
ISurfaceSignalEmitter signalEmitter,
ISurfaceAnalysisWriter writer,
ILogger<SurfaceAnalyzer> logger)
{
_registry = registry;
_signalEmitter = signalEmitter;
_writer = writer;
_logger = logger;
}
public async Task<SurfaceAnalysisResult> AnalyzeAsync(
SurfaceCollectionContext context,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
var startTime = DateTimeOffset.UtcNow;
_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))
{
entries.Add(entry);
}
_logger.LogDebug(
"Collected {EntryCount} surface entries for scan {ScanId}",
entries.Count,
context.ScanId);
// Build summary
var summary = SurfaceAnalysisSummary.FromEntries(entries);
// Create result
var result = new SurfaceAnalysisResult
{
ScanId = context.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
}
};
// Emit signals for policy evaluation
await _signalEmitter.EmitAsync(context.ScanId, result, cancellationToken);
_logger.LogInformation(
"Completed surface analysis for scan {ScanId}: {TotalEntries} entries, risk score {RiskScore:F2}",
context.ScanId,
result.Summary.TotalEntries,
result.Summary.RiskScore);
return result;
}
}