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