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:
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user