feat: Add Go module and workspace test fixtures
- Created expected JSON files for Go modules and workspaces. - Added go.mod and go.sum files for example projects. - Implemented private module structure with expected JSON output. - Introduced vendored dependencies with corresponding expected JSON. - Developed PostgresGraphJobStore for managing graph jobs. - Established SQL migration scripts for graph jobs schema. - Implemented GraphJobRepository for CRUD operations on graph jobs. - Created IGraphJobRepository interface for repository abstraction. - Added unit tests for GraphJobRepository to ensure functionality.
This commit is contained in:
@@ -18,7 +18,94 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
var candidatePaths = new List<string>(GoBinaryScanner.EnumerateCandidateFiles(context.RootPath));
|
||||
// Track emitted modules to avoid duplicates (binary takes precedence over source)
|
||||
var emittedModules = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// Phase 1: Source scanning (go.mod, go.sum, go.work, vendor)
|
||||
ScanSourceFiles(context, writer, emittedModules, cancellationToken);
|
||||
|
||||
// Phase 2: Binary scanning (existing behavior)
|
||||
ScanBinaries(context, writer, emittedModules, cancellationToken);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private void ScanSourceFiles(
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
HashSet<string> emittedModules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Discover Go projects
|
||||
var projects = GoProjectDiscoverer.Discover(context.RootPath, cancellationToken);
|
||||
if (projects.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var project in projects)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
IReadOnlyList<GoSourceInventory.SourceInventoryResult> inventories;
|
||||
|
||||
if (project.IsWorkspace)
|
||||
{
|
||||
// Handle workspace with multiple modules
|
||||
inventories = GoSourceInventory.BuildWorkspaceInventory(project, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single module
|
||||
var inventory = GoSourceInventory.BuildInventory(project);
|
||||
inventories = inventory.IsEmpty
|
||||
? Array.Empty<GoSourceInventory.SourceInventoryResult>()
|
||||
: new[] { inventory };
|
||||
}
|
||||
|
||||
foreach (var inventory in inventories)
|
||||
{
|
||||
if (inventory.IsEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit the main module
|
||||
if (!string.IsNullOrEmpty(inventory.ModulePath))
|
||||
{
|
||||
EmitMainModuleFromSource(inventory, project, context, writer, emittedModules);
|
||||
}
|
||||
|
||||
// Emit dependencies
|
||||
foreach (var module in inventory.Modules.OrderBy(m => m.Path, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EmitSourceModule(module, inventory, project, context, writer, emittedModules);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ScanBinaries(
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
HashSet<string> emittedModules,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidatePaths = new List<string>();
|
||||
|
||||
// Use binary format pre-filtering for efficiency
|
||||
foreach (var path in GoBinaryScanner.EnumerateCandidateFiles(context.RootPath))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Quick check for known binary formats
|
||||
if (GoBinaryFormatDetector.IsPotentialBinary(path))
|
||||
{
|
||||
candidatePaths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
candidatePaths.Sort(StringComparer.Ordinal);
|
||||
|
||||
var fallbackBinaries = new List<GoStrippedBinaryClassification>();
|
||||
@@ -37,7 +124,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
continue;
|
||||
}
|
||||
|
||||
EmitComponents(buildInfo, context, writer);
|
||||
EmitComponents(buildInfo, context, writer, emittedModules);
|
||||
}
|
||||
|
||||
foreach (var fallback in fallbackBinaries)
|
||||
@@ -45,11 +132,197 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
EmitFallbackComponent(fallback, context, writer);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer)
|
||||
private void EmitMainModuleFromSource(
|
||||
GoSourceInventory.SourceInventoryResult inventory,
|
||||
GoProjectDiscoverer.GoProject project,
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
HashSet<string> emittedModules)
|
||||
{
|
||||
// Main module from go.mod (typically no version in source)
|
||||
var modulePath = inventory.ModulePath!;
|
||||
var moduleKey = $"{modulePath}@(devel)";
|
||||
|
||||
if (!emittedModules.Add(moduleKey))
|
||||
{
|
||||
return; // Already emitted
|
||||
}
|
||||
|
||||
var relativePath = context.GetRelativePath(project.RootPath);
|
||||
var goModRelative = project.HasGoMod ? context.GetRelativePath(project.GoModPath!) : null;
|
||||
|
||||
var metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["modulePath"] = modulePath,
|
||||
["modulePath.main"] = modulePath,
|
||||
["provenance"] = "source"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(inventory.GoVersion))
|
||||
{
|
||||
metadata["go.version"] = inventory.GoVersion;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(relativePath))
|
||||
{
|
||||
metadata["projectPath"] = relativePath;
|
||||
}
|
||||
|
||||
if (project.IsWorkspace)
|
||||
{
|
||||
metadata["workspace"] = "true";
|
||||
}
|
||||
|
||||
var evidence = new List<LanguageComponentEvidence>();
|
||||
|
||||
if (!string.IsNullOrEmpty(goModRelative))
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
"go.mod",
|
||||
goModRelative,
|
||||
modulePath,
|
||||
null));
|
||||
}
|
||||
|
||||
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
|
||||
|
||||
// Main module typically has (devel) as version in source context
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: $"golang::source::{modulePath}::(devel)",
|
||||
purl: null,
|
||||
name: modulePath,
|
||||
version: "(devel)",
|
||||
type: "golang",
|
||||
metadata: metadata,
|
||||
evidence: evidence);
|
||||
}
|
||||
|
||||
private void EmitSourceModule(
|
||||
GoSourceInventory.GoSourceModule module,
|
||||
GoSourceInventory.SourceInventoryResult inventory,
|
||||
GoProjectDiscoverer.GoProject project,
|
||||
LanguageAnalyzerContext context,
|
||||
LanguageComponentWriter writer,
|
||||
HashSet<string> emittedModules)
|
||||
{
|
||||
var moduleKey = $"{module.Path}@{module.Version}";
|
||||
|
||||
if (!emittedModules.Add(moduleKey))
|
||||
{
|
||||
return; // Already emitted (binary takes precedence)
|
||||
}
|
||||
|
||||
var purl = BuildPurl(module.Path, module.Version);
|
||||
var goModRelative = project.HasGoMod ? context.GetRelativePath(project.GoModPath!) : null;
|
||||
|
||||
var metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["modulePath"] = module.Path,
|
||||
["moduleVersion"] = module.Version,
|
||||
["provenance"] = "source"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(module.Checksum))
|
||||
{
|
||||
metadata["moduleSum"] = module.Checksum;
|
||||
}
|
||||
|
||||
if (module.IsDirect)
|
||||
{
|
||||
metadata["dependency.direct"] = "true";
|
||||
}
|
||||
|
||||
if (module.IsIndirect)
|
||||
{
|
||||
metadata["dependency.indirect"] = "true";
|
||||
}
|
||||
|
||||
if (module.IsVendored)
|
||||
{
|
||||
metadata["vendored"] = "true";
|
||||
}
|
||||
|
||||
if (module.IsPrivate)
|
||||
{
|
||||
metadata["private"] = "true";
|
||||
}
|
||||
|
||||
if (module.ModuleCategory != "public")
|
||||
{
|
||||
metadata["moduleCategory"] = module.ModuleCategory;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(module.Registry))
|
||||
{
|
||||
metadata["registry"] = module.Registry;
|
||||
}
|
||||
|
||||
if (module.IsReplaced)
|
||||
{
|
||||
metadata["replaced"] = "true";
|
||||
|
||||
if (!string.IsNullOrEmpty(module.ReplacementPath))
|
||||
{
|
||||
metadata["replacedBy.path"] = module.ReplacementPath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(module.ReplacementVersion))
|
||||
{
|
||||
metadata["replacedBy.version"] = module.ReplacementVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (module.IsExcluded)
|
||||
{
|
||||
metadata["excluded"] = "true";
|
||||
}
|
||||
|
||||
var evidence = new List<LanguageComponentEvidence>();
|
||||
|
||||
// Evidence from go.mod
|
||||
if (!string.IsNullOrEmpty(goModRelative))
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.Metadata,
|
||||
module.Source,
|
||||
goModRelative,
|
||||
$"{module.Path}@{module.Version}",
|
||||
module.Checksum));
|
||||
}
|
||||
|
||||
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
|
||||
|
||||
if (!string.IsNullOrEmpty(purl))
|
||||
{
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: purl,
|
||||
name: module.Path,
|
||||
version: module.Version,
|
||||
type: "golang",
|
||||
metadata: metadata,
|
||||
evidence: evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: $"golang::source::{module.Path}@{module.Version}",
|
||||
purl: null,
|
||||
name: module.Path,
|
||||
version: module.Version,
|
||||
type: "golang",
|
||||
metadata: metadata,
|
||||
evidence: evidence);
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer, HashSet<string> emittedModules)
|
||||
{
|
||||
var components = new List<GoModule> { buildInfo.MainModule };
|
||||
components.AddRange(buildInfo.Dependencies
|
||||
@@ -61,6 +334,10 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
|
||||
|
||||
foreach (var module in components)
|
||||
{
|
||||
// Track emitted modules (binary evidence is more accurate than source)
|
||||
var moduleKey = $"{module.Path}@{module.Version ?? "(devel)"}";
|
||||
emittedModules.Add(moduleKey);
|
||||
|
||||
var metadata = BuildMetadata(buildInfo, module, binaryRelativePath);
|
||||
var evidence = BuildEvidence(buildInfo, module, binaryRelativePath, context, ref binaryHash);
|
||||
var usedByEntrypoint = module.IsMain && context.UsageHints.IsPathUsed(buildInfo.AbsoluteBinaryPath);
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Buffers;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Detects binary file formats to quickly filter candidates for Go binary scanning.
|
||||
/// Identifies ELF (Linux), PE (Windows), and Mach-O (macOS) formats.
|
||||
/// </summary>
|
||||
internal static class GoBinaryFormatDetector
|
||||
{
|
||||
// Magic bytes for different formats
|
||||
private static readonly byte[] ElfMagic = [0x7F, (byte)'E', (byte)'L', (byte)'F'];
|
||||
private static readonly byte[] PeMagic = [(byte)'M', (byte)'Z'];
|
||||
private static readonly byte[] MachO32Magic = [0xFE, 0xED, 0xFA, 0xCE];
|
||||
private static readonly byte[] MachO64Magic = [0xFE, 0xED, 0xFA, 0xCF];
|
||||
private static readonly byte[] MachO32MagicReverse = [0xCE, 0xFA, 0xED, 0xFE];
|
||||
private static readonly byte[] MachO64MagicReverse = [0xCF, 0xFA, 0xED, 0xFE];
|
||||
private static readonly byte[] FatMagic = [0xCA, 0xFE, 0xBA, 0xBE]; // Universal binary
|
||||
|
||||
/// <summary>
|
||||
/// Binary format type.
|
||||
/// </summary>
|
||||
public enum BinaryFormat
|
||||
{
|
||||
Unknown,
|
||||
Elf,
|
||||
Pe,
|
||||
MachO,
|
||||
Fat // Universal/Fat binary (contains multiple architectures)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of binary format detection.
|
||||
/// </summary>
|
||||
public readonly record struct DetectionResult(
|
||||
BinaryFormat Format,
|
||||
bool IsExecutable,
|
||||
string? Architecture);
|
||||
|
||||
/// <summary>
|
||||
/// Quickly checks if a file is likely a binary executable.
|
||||
/// </summary>
|
||||
public static bool IsPotentialBinary(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
if (stream.Length < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<byte> header = stackalloc byte[4];
|
||||
var read = stream.Read(header);
|
||||
if (read < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsKnownBinaryFormat(header);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects the binary format and extracts basic metadata.
|
||||
/// </summary>
|
||||
public static DetectionResult Detect(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Unknown, false, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return DetectFromStream(stream);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Unknown, false, null);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Unknown, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects format from a stream.
|
||||
/// </summary>
|
||||
public static DetectionResult DetectFromStream(Stream stream)
|
||||
{
|
||||
if (stream.Length < 64)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Unknown, false, null);
|
||||
}
|
||||
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(64);
|
||||
|
||||
try
|
||||
{
|
||||
var read = stream.Read(buffer, 0, 64);
|
||||
if (read < 4)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Unknown, false, null);
|
||||
}
|
||||
|
||||
var header = new ReadOnlySpan<byte>(buffer, 0, read);
|
||||
|
||||
// Check ELF
|
||||
if (header[..4].SequenceEqual(ElfMagic))
|
||||
{
|
||||
return DetectElf(header);
|
||||
}
|
||||
|
||||
// Check PE (MZ header)
|
||||
if (header[..2].SequenceEqual(PeMagic))
|
||||
{
|
||||
return DetectPe(header, stream);
|
||||
}
|
||||
|
||||
// Check Mach-O
|
||||
if (header[..4].SequenceEqual(MachO32Magic) ||
|
||||
header[..4].SequenceEqual(MachO64Magic) ||
|
||||
header[..4].SequenceEqual(MachO32MagicReverse) ||
|
||||
header[..4].SequenceEqual(MachO64MagicReverse))
|
||||
{
|
||||
return DetectMachO(header);
|
||||
}
|
||||
|
||||
// Check Fat binary
|
||||
if (header[..4].SequenceEqual(FatMagic))
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Fat, true, "universal");
|
||||
}
|
||||
|
||||
return new DetectionResult(BinaryFormat.Unknown, false, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsKnownBinaryFormat(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (header.Length < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ELF
|
||||
if (header[..4].SequenceEqual(ElfMagic))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// PE
|
||||
if (header[..2].SequenceEqual(PeMagic))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mach-O (all variants)
|
||||
if (header[..4].SequenceEqual(MachO32Magic) ||
|
||||
header[..4].SequenceEqual(MachO64Magic) ||
|
||||
header[..4].SequenceEqual(MachO32MagicReverse) ||
|
||||
header[..4].SequenceEqual(MachO64MagicReverse) ||
|
||||
header[..4].SequenceEqual(FatMagic))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DetectionResult DetectElf(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (header.Length < 20)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Elf, true, null);
|
||||
}
|
||||
|
||||
// ELF class (32 or 64 bit)
|
||||
var elfClass = header[4];
|
||||
var is64Bit = elfClass == 2;
|
||||
|
||||
// ELF type (offset 16-17)
|
||||
var elfType = header[16];
|
||||
var isExecutable = elfType == 2 || elfType == 3; // ET_EXEC or ET_DYN
|
||||
|
||||
// Machine type (offset 18-19)
|
||||
var machine = header[18];
|
||||
var arch = machine switch
|
||||
{
|
||||
0x03 => "386",
|
||||
0x3E => "amd64",
|
||||
0xB7 => "arm64",
|
||||
0x28 => "arm",
|
||||
0xF3 => "riscv64",
|
||||
0x08 => "mips",
|
||||
0x14 => "ppc",
|
||||
0x15 => "ppc64",
|
||||
0x16 => "s390x",
|
||||
_ => is64Bit ? "64-bit" : "32-bit"
|
||||
};
|
||||
|
||||
return new DetectionResult(BinaryFormat.Elf, isExecutable, arch);
|
||||
}
|
||||
|
||||
private static DetectionResult DetectPe(ReadOnlySpan<byte> header, Stream stream)
|
||||
{
|
||||
// PE files have PE\0\0 signature at offset specified in header
|
||||
if (header.Length < 64)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Pe, true, null);
|
||||
}
|
||||
|
||||
// Get PE header offset from offset 0x3C
|
||||
var peOffset = BitConverter.ToInt32(header.Slice(0x3C, 4));
|
||||
if (peOffset < 0 || peOffset > stream.Length - 6)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Pe, true, null);
|
||||
}
|
||||
|
||||
// Read PE header
|
||||
stream.Position = peOffset;
|
||||
Span<byte> peHeader = stackalloc byte[6];
|
||||
if (stream.Read(peHeader) < 6)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Pe, true, null);
|
||||
}
|
||||
|
||||
// Verify PE signature
|
||||
if (peHeader[0] != 'P' || peHeader[1] != 'E' || peHeader[2] != 0 || peHeader[3] != 0)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.Pe, true, null);
|
||||
}
|
||||
|
||||
// Machine type
|
||||
var machine = BitConverter.ToUInt16(peHeader.Slice(4, 2));
|
||||
var arch = machine switch
|
||||
{
|
||||
0x014C => "386",
|
||||
0x8664 => "amd64",
|
||||
0xAA64 => "arm64",
|
||||
0x01C4 => "arm",
|
||||
_ => null
|
||||
};
|
||||
|
||||
return new DetectionResult(BinaryFormat.Pe, true, arch);
|
||||
}
|
||||
|
||||
private static DetectionResult DetectMachO(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (header.Length < 8)
|
||||
{
|
||||
return new DetectionResult(BinaryFormat.MachO, true, null);
|
||||
}
|
||||
|
||||
// Check endianness and word size
|
||||
var is64Bit = header[..4].SequenceEqual(MachO64Magic) || header[..4].SequenceEqual(MachO64MagicReverse);
|
||||
var isLittleEndian = header[..4].SequenceEqual(MachO32MagicReverse) || header[..4].SequenceEqual(MachO64MagicReverse);
|
||||
|
||||
// CPU type is at offset 4
|
||||
int cpuType;
|
||||
if (isLittleEndian)
|
||||
{
|
||||
cpuType = BitConverter.ToInt32(header.Slice(4, 4));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Big endian
|
||||
cpuType = (header[4] << 24) | (header[5] << 16) | (header[6] << 8) | header[7];
|
||||
}
|
||||
|
||||
var arch = (cpuType & 0xFF) switch
|
||||
{
|
||||
7 => is64Bit ? "amd64" : "386",
|
||||
12 => is64Bit ? "arm64" : "arm",
|
||||
18 => is64Bit ? "ppc64" : "ppc",
|
||||
_ => is64Bit ? "64-bit" : "32-bit"
|
||||
};
|
||||
|
||||
return new DetectionResult(BinaryFormat.MachO, true, arch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates Go module dependencies from source files (go.mod, go.sum, vendor/modules.txt).
|
||||
/// </summary>
|
||||
internal static class GoSourceInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// A Go module discovered from source files.
|
||||
/// </summary>
|
||||
public sealed record GoSourceModule
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Checksum { get; init; }
|
||||
public bool IsDirect { get; init; }
|
||||
public bool IsIndirect { get; init; }
|
||||
public bool IsVendored { get; init; }
|
||||
public bool IsReplaced { get; init; }
|
||||
public bool IsExcluded { get; init; }
|
||||
public bool IsRetracted { get; init; }
|
||||
public bool IsPrivate { get; init; }
|
||||
public string? ReplacementPath { get; init; }
|
||||
public string? ReplacementVersion { get; init; }
|
||||
public string Source { get; init; } = "go.mod";
|
||||
public string ModuleCategory { get; init; } = "public";
|
||||
public string? Registry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inventory results from source scanning.
|
||||
/// </summary>
|
||||
public sealed record SourceInventoryResult
|
||||
{
|
||||
public static readonly SourceInventoryResult Empty = new(
|
||||
null,
|
||||
null,
|
||||
ImmutableArray<GoSourceModule>.Empty,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
public SourceInventoryResult(
|
||||
string? modulePath,
|
||||
string? goVersion,
|
||||
ImmutableArray<GoSourceModule> modules,
|
||||
ImmutableArray<string> retractedVersions)
|
||||
{
|
||||
ModulePath = modulePath;
|
||||
GoVersion = goVersion;
|
||||
Modules = modules;
|
||||
RetractedVersions = retractedVersions;
|
||||
}
|
||||
|
||||
public string? ModulePath { get; }
|
||||
public string? GoVersion { get; }
|
||||
public ImmutableArray<GoSourceModule> Modules { get; }
|
||||
public ImmutableArray<string> RetractedVersions { get; }
|
||||
|
||||
public bool IsEmpty => Modules.IsEmpty && string.IsNullOrEmpty(ModulePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds inventory from a discovered Go project.
|
||||
/// </summary>
|
||||
public static SourceInventoryResult BuildInventory(GoProjectDiscoverer.GoProject project)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(project);
|
||||
|
||||
if (!project.HasGoMod)
|
||||
{
|
||||
return SourceInventoryResult.Empty;
|
||||
}
|
||||
|
||||
// Parse go.mod
|
||||
var goMod = GoModParser.Parse(project.GoModPath!);
|
||||
if (goMod.IsEmpty)
|
||||
{
|
||||
return SourceInventoryResult.Empty;
|
||||
}
|
||||
|
||||
// Parse go.sum for checksums
|
||||
var goSum = project.HasGoSum
|
||||
? GoSumParser.Parse(project.GoSumPath!)
|
||||
: GoSumParser.GoSumData.Empty;
|
||||
|
||||
// Parse vendor/modules.txt if present
|
||||
var vendorData = project.HasVendor
|
||||
? GoVendorParser.Parse(project.VendorModulesPath!)
|
||||
: GoVendorParser.GoVendorData.Empty;
|
||||
|
||||
// Build replacement map
|
||||
var replacements = goMod.Replaces
|
||||
.ToImmutableDictionary(
|
||||
r => r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath,
|
||||
r => r,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// Build exclude set
|
||||
var excludes = goMod.Excludes
|
||||
.Select(e => $"{e.Path}@{e.Version}")
|
||||
.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
|
||||
// Build retracted set (these are versions of this module that are retracted)
|
||||
var retractedVersions = goMod.Retracts.ToImmutableArray();
|
||||
|
||||
// Process requires
|
||||
var modules = new List<GoSourceModule>();
|
||||
|
||||
foreach (var req in goMod.Requires)
|
||||
{
|
||||
var checksum = goSum.GetHash(req.Path, req.Version);
|
||||
var isVendored = vendorData.IsVendored(req.Path);
|
||||
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(req.Path);
|
||||
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(req.Path);
|
||||
var registry = GoPrivateModuleDetector.GetRegistry(req.Path);
|
||||
|
||||
// Check for replacement
|
||||
GoModParser.GoModReplace? replacement = null;
|
||||
var versionedKey = $"{req.Path}@{req.Version}";
|
||||
if (replacements.TryGetValue(versionedKey, out replacement) ||
|
||||
replacements.TryGetValue(req.Path, out replacement))
|
||||
{
|
||||
// Module is replaced
|
||||
}
|
||||
|
||||
// Check if excluded
|
||||
var isExcluded = excludes.Contains(versionedKey);
|
||||
|
||||
var module = new GoSourceModule
|
||||
{
|
||||
Path = req.Path,
|
||||
Version = req.Version,
|
||||
Checksum = checksum,
|
||||
IsDirect = !req.IsIndirect,
|
||||
IsIndirect = req.IsIndirect,
|
||||
IsVendored = isVendored,
|
||||
IsReplaced = replacement is not null,
|
||||
IsExcluded = isExcluded,
|
||||
IsRetracted = false, // Can't know without checking the module's go.mod
|
||||
IsPrivate = isPrivate,
|
||||
ReplacementPath = replacement?.NewPath,
|
||||
ReplacementVersion = replacement?.NewVersion,
|
||||
Source = isVendored ? "vendor" : "go.mod",
|
||||
ModuleCategory = moduleCategory,
|
||||
Registry = registry
|
||||
};
|
||||
|
||||
modules.Add(module);
|
||||
}
|
||||
|
||||
// Add vendored modules not in requires (explicit vendored deps)
|
||||
if (!vendorData.IsEmpty)
|
||||
{
|
||||
var requirePaths = goMod.Requires
|
||||
.Select(r => r.Path)
|
||||
.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var vendorMod in vendorData.Modules)
|
||||
{
|
||||
if (!requirePaths.Contains(vendorMod.Path))
|
||||
{
|
||||
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(vendorMod.Path);
|
||||
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(vendorMod.Path);
|
||||
|
||||
modules.Add(new GoSourceModule
|
||||
{
|
||||
Path = vendorMod.Path,
|
||||
Version = vendorMod.Version,
|
||||
Checksum = goSum.GetHash(vendorMod.Path, vendorMod.Version),
|
||||
IsDirect = vendorMod.IsExplicit,
|
||||
IsIndirect = !vendorMod.IsExplicit,
|
||||
IsVendored = true,
|
||||
IsReplaced = false,
|
||||
IsExcluded = false,
|
||||
IsRetracted = false,
|
||||
IsPrivate = isPrivate,
|
||||
Source = "vendor",
|
||||
ModuleCategory = moduleCategory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SourceInventoryResult(
|
||||
goMod.ModulePath,
|
||||
goMod.GoVersion,
|
||||
modules.ToImmutableArray(),
|
||||
retractedVersions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds combined inventory for a workspace (all members).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<SourceInventoryResult> BuildWorkspaceInventory(
|
||||
GoProjectDiscoverer.GoProject workspaceProject,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workspaceProject);
|
||||
|
||||
var results = new List<SourceInventoryResult>();
|
||||
|
||||
// Build inventory for workspace root if it has go.mod
|
||||
if (workspaceProject.HasGoMod)
|
||||
{
|
||||
var rootInventory = BuildInventory(workspaceProject);
|
||||
if (!rootInventory.IsEmpty)
|
||||
{
|
||||
results.Add(rootInventory);
|
||||
}
|
||||
}
|
||||
|
||||
// Build inventory for each workspace member
|
||||
foreach (var memberPath in workspaceProject.WorkspaceMembers)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var memberFullPath = Path.Combine(workspaceProject.RootPath, memberPath);
|
||||
var memberGoMod = Path.Combine(memberFullPath, "go.mod");
|
||||
var memberGoSum = Path.Combine(memberFullPath, "go.sum");
|
||||
var memberVendor = Path.Combine(memberFullPath, "vendor", "modules.txt");
|
||||
|
||||
var memberProject = new GoProjectDiscoverer.GoProject(
|
||||
memberFullPath,
|
||||
File.Exists(memberGoMod) ? memberGoMod : null,
|
||||
File.Exists(memberGoSum) ? memberGoSum : null,
|
||||
null,
|
||||
File.Exists(memberVendor) ? memberVendor : null,
|
||||
ImmutableArray<string>.Empty);
|
||||
|
||||
if (memberProject.HasGoMod)
|
||||
{
|
||||
var memberInventory = BuildInventory(memberProject);
|
||||
if (!memberInventory.IsEmpty)
|
||||
{
|
||||
results.Add(memberInventory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user