up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 18:08:55 +02:00
parent 6e45066e37
commit f1a39c4ce3
234 changed files with 24038 additions and 6910 deletions

View File

@@ -18,14 +18,19 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
// Track emitted modules to avoid duplicates (binary takes precedence over source)
// Track emitted modules to avoid duplicates.
// Key format: "path@version" for versioned deps, "path@main" for main modules.
// Binary evidence takes precedence over source evidence - scan binaries first.
var emittedModules = new HashSet<string>(StringComparer.Ordinal);
// Track main module paths separately so source (devel) main modules are suppressed
// when binary evidence exists for the same module path.
var emittedMainModulePaths = new HashSet<string>(StringComparer.Ordinal);
// Phase 1: Source scanning (go.mod, go.sum, go.work, vendor)
ScanSourceFiles(context, writer, emittedModules, cancellationToken);
// Phase 1: Binary scanning (binary evidence is authoritative and takes precedence)
ScanBinaries(context, writer, emittedModules, emittedMainModulePaths, cancellationToken);
// Phase 2: Binary scanning (existing behavior)
ScanBinaries(context, writer, emittedModules, cancellationToken);
// Phase 2: Source scanning (go.mod, go.sum, go.work, vendor) - skips modules with binary evidence
ScanSourceFiles(context, writer, emittedModules, emittedMainModulePaths, cancellationToken);
return ValueTask.CompletedTask;
}
@@ -34,6 +39,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
HashSet<string> emittedModules,
HashSet<string> emittedMainModulePaths,
CancellationToken cancellationToken)
{
// Discover Go projects
@@ -70,17 +76,17 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
continue;
}
// Emit the main module
// Emit the main module (skip if binary evidence already exists for this module path)
if (!string.IsNullOrEmpty(inventory.ModulePath))
{
EmitMainModuleFromSource(inventory, project, context, writer, emittedModules);
EmitMainModuleFromSource(inventory, project, context, writer, emittedModules, emittedMainModulePaths);
}
// Emit dependencies
// Emit dependencies (skip if binary evidence already exists)
foreach (var module in inventory.Modules.OrderBy(m => m.Path, StringComparer.Ordinal))
{
cancellationToken.ThrowIfCancellationRequested();
EmitSourceModule(module, inventory, project, context, writer, emittedModules);
EmitSourceModule(module, inventory, project, context, writer, emittedModules, emittedMainModulePaths);
}
}
}
@@ -90,6 +96,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
HashSet<string> emittedModules,
HashSet<string> emittedMainModulePaths,
CancellationToken cancellationToken)
{
var candidatePaths = new List<string>();
@@ -124,7 +131,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
continue;
}
EmitComponents(buildInfo, context, writer, emittedModules);
EmitComponents(buildInfo, context, writer, emittedModules, emittedMainModulePaths);
}
foreach (var fallback in fallbackBinaries)
@@ -139,15 +146,23 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
GoProjectDiscoverer.GoProject project,
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
HashSet<string> emittedModules)
HashSet<string> emittedModules,
HashSet<string> emittedMainModulePaths)
{
// Main module from go.mod (typically no version in source)
var modulePath = inventory.ModulePath!;
var moduleKey = $"{modulePath}@(devel)";
// If binary evidence already exists for this main module, skip source emission.
// Binary main modules have concrete build info and take precedence over source (devel).
if (emittedMainModulePaths.Contains(modulePath))
{
return; // Binary evidence takes precedence
}
var moduleKey = $"{modulePath}@(devel)";
if (!emittedModules.Add(moduleKey))
{
return; // Already emitted
return; // Already emitted from another source location
}
var relativePath = context.GetRelativePath(project.RootPath);
@@ -239,6 +254,45 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
null));
}
// Add capability metadata and evidence
if (inventory.Capabilities.Length > 0)
{
// Summarize capability kinds
var capabilityKinds = inventory.Capabilities
.Select(c => c.Kind.ToString().ToLowerInvariant())
.Distinct()
.OrderBy(k => k)
.ToList();
metadata["capabilities"] = string.Join(",", capabilityKinds);
// Add risk summary
if (inventory.HasCriticalCapabilities)
{
metadata["capabilities.maxRisk"] = "critical";
}
else if (inventory.HasHighRiskCapabilities)
{
metadata["capabilities.maxRisk"] = "high";
}
// Add top capability evidence entries (limited to avoid noise)
var topCapabilities = inventory.Capabilities
.OrderByDescending(c => c.Risk)
.ThenBy(c => c.SourceFile)
.ThenBy(c => c.SourceLine)
.Take(10);
foreach (var capability in topCapabilities)
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
$"capability:{capability.Kind.ToString().ToLowerInvariant()}",
$"{capability.SourceFile}:{capability.SourceLine}",
capability.Pattern,
null));
}
}
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
// Main module typically has (devel) as version in source context
@@ -259,10 +313,12 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
GoProjectDiscoverer.GoProject project,
LanguageAnalyzerContext context,
LanguageComponentWriter writer,
HashSet<string> emittedModules)
HashSet<string> emittedModules,
HashSet<string> emittedMainModulePaths)
{
var moduleKey = $"{module.Path}@{module.Version}";
// Binary evidence takes precedence - if already emitted with same path@version, skip
if (!emittedModules.Add(moduleKey))
{
return; // Already emitted (binary takes precedence)
@@ -405,7 +461,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
}
}
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer, HashSet<string> emittedModules)
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer, HashSet<string> emittedModules, HashSet<string> emittedMainModulePaths)
{
var components = new List<GoModule> { buildInfo.MainModule };
components.AddRange(buildInfo.Dependencies
@@ -417,10 +473,16 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
foreach (var module in components)
{
// Track emitted modules (binary evidence is more accurate than source)
// Track emitted modules (binary evidence is authoritative and takes precedence over source)
var moduleKey = $"{module.Path}@{module.Version ?? "(devel)"}";
emittedModules.Add(moduleKey);
// Track main module paths so source (devel) versions are suppressed
if (module.IsMain)
{
emittedMainModulePaths.Add(module.Path);
}
var metadata = BuildMetadata(buildInfo, module, binaryRelativePath);
var evidence = BuildEvidence(buildInfo, module, binaryRelativePath, context, ref binaryHash);
var usedByEntrypoint = module.IsMain && context.UsageHints.IsPathUsed(buildInfo.AbsoluteBinaryPath);
@@ -463,6 +525,7 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
{
new("modulePath", module.Path),
new("binaryPath", string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath),
new("provenance", "binary"),
};
if (!string.IsNullOrEmpty(module.Version))

View File

@@ -72,6 +72,21 @@ internal static class GoBinaryScanner
}
}
/// <summary>
/// Maximum file size to scan (128 MB). Files larger than this are skipped.
/// </summary>
private const long MaxFileSizeBytes = 128 * 1024 * 1024;
/// <summary>
/// Window size for bounded reads (16 MB). We scan in chunks to avoid loading entire files.
/// </summary>
private const int WindowSizeBytes = 16 * 1024 * 1024;
/// <summary>
/// Overlap between windows to catch magic bytes at window boundaries.
/// </summary>
private const int WindowOverlapBytes = 4096;
public static bool TryReadBuildInfo(string filePath, out string? goVersion, out string? moduleData)
{
goVersion = null;
@@ -81,7 +96,7 @@ internal static class GoBinaryScanner
try
{
info = new FileInfo(filePath);
if (!info.Exists || info.Length < 64 || info.Length > 128 * 1024 * 1024)
if (!info.Exists || info.Length < 64 || info.Length > MaxFileSizeBytes)
{
return false;
}
@@ -105,31 +120,45 @@ internal static class GoBinaryScanner
return false;
}
var inspectLength = (int)Math.Min(length, int.MaxValue);
var buffer = ArrayPool<byte>.Shared.Rent(inspectLength);
// For small files, read the entire content
if (length <= WindowSizeBytes)
{
return TryReadBuildInfoDirect(filePath, (int)length, out goVersion, out moduleData);
}
// For larger files, use windowed scanning to bound memory usage
return TryReadBuildInfoWindowed(filePath, length, out goVersion, out moduleData);
}
private static bool TryReadBuildInfoDirect(string filePath, int length, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
var buffer = ArrayPool<byte>.Shared.Rent(length);
var bytesRead = 0;
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var totalRead = 0;
while (totalRead < inspectLength)
while (bytesRead < length)
{
var read = stream.Read(buffer, totalRead, inspectLength - totalRead);
var read = stream.Read(buffer, bytesRead, length - bytesRead);
if (read <= 0)
{
break;
}
totalRead += read;
bytesRead += read;
}
if (totalRead < 64)
if (bytesRead < 64)
{
return false;
}
var span = new ReadOnlySpan<byte>(buffer, 0, totalRead);
var span = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
var offset = span.IndexOf(BuildInfoMagic.Span);
if (offset < 0)
{
@@ -149,7 +178,81 @@ internal static class GoBinaryScanner
}
finally
{
Array.Clear(buffer, 0, inspectLength);
Array.Clear(buffer, 0, bytesRead);
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static bool TryReadBuildInfoWindowed(string filePath, long length, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
var buffer = ArrayPool<byte>.Shared.Rent(WindowSizeBytes);
var bytesRead = 0;
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
long position = 0;
while (position < length)
{
// Calculate read size (with overlap for boundaries)
var readSize = (int)Math.Min(WindowSizeBytes, length - position);
// Seek to position (accounting for overlap on subsequent windows)
if (position > 0)
{
stream.Seek(position - WindowOverlapBytes, SeekOrigin.Begin);
readSize = (int)Math.Min(WindowSizeBytes, length - position + WindowOverlapBytes);
}
bytesRead = 0;
while (bytesRead < readSize)
{
var read = stream.Read(buffer, bytesRead, readSize - bytesRead);
if (read <= 0)
{
break;
}
bytesRead += read;
}
if (bytesRead < 64)
{
position += WindowSizeBytes - WindowOverlapBytes;
continue;
}
var span = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
var offset = span.IndexOf(BuildInfoMagic.Span);
if (offset >= 0)
{
var view = span[offset..];
if (GoBuildInfoDecoder.TryDecode(view, out goVersion, out moduleData))
{
return true;
}
}
position += WindowSizeBytes - WindowOverlapBytes;
}
return false;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
finally
{
Array.Clear(buffer, 0, bytesRead);
ArrayPool<byte>.Shared.Return(buffer);
}
}

View File

@@ -1,7 +1,9 @@
using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.IO;
using System.Security;
using System.Security.Cryptography;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
@@ -9,6 +11,12 @@ internal static class GoBuildInfoProvider
{
private static readonly ConcurrentDictionary<GoBinaryCacheKey, GoBuildInfo?> Cache = new();
/// <summary>
/// Size of header to hash for cache key (4 KB). This handles container layer edge cases
/// where files may have the same path/size/mtime but different content.
/// </summary>
private const int HeaderHashSize = 4096;
public static bool TryGetBuildInfo(string absolutePath, out GoBuildInfo? info)
{
info = null;
@@ -35,11 +43,64 @@ internal static class GoBuildInfoProvider
return false;
}
var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
// Compute bounded header hash for cache key robustness in layered filesystems
var headerHash = ComputeHeaderHash(absolutePath);
var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks, headerHash);
info = Cache.GetOrAdd(key, static (cacheKey, path) => CreateBuildInfo(path), absolutePath);
return info is not null;
}
/// <summary>
/// Computes a truncated hash of the file header for cache key disambiguation.
/// This handles edge cases in container layers where files may have identical metadata.
/// </summary>
private static long ComputeHeaderHash(string path)
{
try
{
var buffer = ArrayPool<byte>.Shared.Rent(HeaderHashSize);
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var bytesRead = stream.Read(buffer, 0, HeaderHashSize);
if (bytesRead <= 0)
{
return 0;
}
// Use XxHash64 for speed (non-cryptographic, but fast and well-distributed)
// Fall back to simple hash if not available
return ComputeSimpleHash(buffer.AsSpan(0, bytesRead));
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
catch
{
return 0;
}
}
/// <summary>
/// Simple FNV-1a inspired hash for header bytes.
/// </summary>
private static long ComputeSimpleHash(ReadOnlySpan<byte> data)
{
const long fnvPrime = 0x00000100000001B3;
const long fnvOffsetBasis = unchecked((long)0xcbf29ce484222325);
var hash = fnvOffsetBasis;
foreach (var b in data)
{
hash ^= b;
hash *= fnvPrime;
}
return hash;
}
private static GoBuildInfo? CreateBuildInfo(string absolutePath)
{
if (!GoBinaryScanner.TryReadBuildInfo(absolutePath, out var goVersion, out var moduleData))
@@ -65,7 +126,11 @@ internal static class GoBuildInfoProvider
return buildInfo;
}
private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks)
/// <summary>
/// Cache key for Go binaries. Includes path, length, mtime, and a bounded header hash
/// for robustness in containerized/layered filesystem environments.
/// </summary>
private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks, long HeaderHash)
{
private readonly string _normalizedPath = OperatingSystem.IsWindows()
? Path.ToLowerInvariant()
@@ -74,9 +139,10 @@ internal static class GoBuildInfoProvider
public bool Equals(GoBinaryCacheKey other)
=> Length == other.Length
&& LastWriteTicks == other.LastWriteTicks
&& HeaderHash == other.HeaderHash
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks, HeaderHash);
}
}

View File

@@ -12,6 +12,22 @@ internal static class GoDwarfReader
private static readonly byte[] VcsModifiedToken = Encoding.UTF8.GetBytes("vcs.modified=");
private static readonly byte[] VcsTimeToken = Encoding.UTF8.GetBytes("vcs.time=");
/// <summary>
/// Maximum file size to scan (256 MB). Files larger than this are skipped.
/// </summary>
private const long MaxFileSizeBytes = 256 * 1024 * 1024;
/// <summary>
/// Window size for bounded reads (8 MB). VCS tokens are typically in build info sections,
/// not spread throughout the binary.
/// </summary>
private const int WindowSizeBytes = 8 * 1024 * 1024;
/// <summary>
/// Overlap between windows to catch tokens at window boundaries.
/// </summary>
private const int WindowOverlapBytes = 1024;
public static bool TryRead(string path, out GoDwarfMetadata? metadata)
{
metadata = null;
@@ -30,32 +46,108 @@ internal static class GoDwarfReader
return false;
}
if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > 256 * 1024 * 1024)
if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > MaxFileSizeBytes)
{
return false;
}
var length = fileInfo.Length;
var readLength = (int)Math.Min(length, int.MaxValue);
var buffer = ArrayPool<byte>.Shared.Rent(readLength);
// For small files, read the entire content
if (length <= WindowSizeBytes)
{
return TryReadDirect(path, (int)length, out metadata);
}
// For larger files, use windowed scanning to bound memory usage
return TryReadWindowed(path, length, out metadata);
}
private static bool TryReadDirect(string path, int length, out GoDwarfMetadata? metadata)
{
metadata = null;
var buffer = ArrayPool<byte>.Shared.Rent(length);
var bytesRead = 0;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
bytesRead = stream.Read(buffer, 0, readLength);
bytesRead = stream.Read(buffer, 0, length);
if (bytesRead <= 0)
{
return false;
}
var data = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
return TryExtractMetadata(data, out metadata);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
finally
{
Array.Clear(buffer, 0, bytesRead);
ArrayPool<byte>.Shared.Return(buffer);
}
}
var revision = ExtractValue(data, VcsRevisionToken);
var modifiedText = ExtractValue(data, VcsModifiedToken);
var timestamp = ExtractValue(data, VcsTimeToken);
var system = ExtractValue(data, VcsSystemToken);
private static bool TryReadWindowed(string path, long length, out GoDwarfMetadata? metadata)
{
metadata = null;
var buffer = ArrayPool<byte>.Shared.Rent(WindowSizeBytes);
var bytesRead = 0;
// Track found values across windows (they may be spread or we find them in different windows)
string? revision = null;
string? modifiedText = null;
string? timestamp = null;
string? system = null;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
long position = 0;
while (position < length)
{
var readSize = (int)Math.Min(WindowSizeBytes, length - position);
// Seek to position (accounting for overlap on subsequent windows)
if (position > 0)
{
stream.Seek(position - WindowOverlapBytes, SeekOrigin.Begin);
readSize = (int)Math.Min(WindowSizeBytes, length - position + WindowOverlapBytes);
}
bytesRead = stream.Read(buffer, 0, readSize);
if (bytesRead <= 0)
{
break;
}
var data = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
// Try to extract values from this window
revision ??= ExtractValue(data, VcsRevisionToken);
modifiedText ??= ExtractValue(data, VcsModifiedToken);
timestamp ??= ExtractValue(data, VcsTimeToken);
system ??= ExtractValue(data, VcsSystemToken);
// Early exit if we found all values
if (revision is not null && modifiedText is not null && timestamp is not null && system is not null)
{
break;
}
position += WindowSizeBytes - WindowOverlapBytes;
}
// Build metadata from collected values
bool? modified = null;
if (!string.IsNullOrWhiteSpace(modifiedText))
{
@@ -88,6 +180,33 @@ internal static class GoDwarfReader
}
}
private static bool TryExtractMetadata(ReadOnlySpan<byte> data, out GoDwarfMetadata? metadata)
{
metadata = null;
var revision = ExtractValue(data, VcsRevisionToken);
var modifiedText = ExtractValue(data, VcsModifiedToken);
var timestamp = ExtractValue(data, VcsTimeToken);
var system = ExtractValue(data, VcsSystemToken);
bool? modified = null;
if (!string.IsNullOrWhiteSpace(modifiedText))
{
if (bool.TryParse(modifiedText, out var parsed))
{
modified = parsed;
}
}
if (string.IsNullOrWhiteSpace(revision) && string.IsNullOrWhiteSpace(system) && modified is null && string.IsNullOrWhiteSpace(timestamp))
{
return false;
}
metadata = new GoDwarfMetadata(system, revision, modified, timestamp);
return true;
}
private static string? ExtractValue(ReadOnlySpan<byte> data, ReadOnlySpan<byte> token)
{
var index = data.IndexOf(token);

View File

@@ -18,7 +18,8 @@ internal static class GoProjectDiscoverer
string? goSumPath,
string? goWorkPath,
string? vendorModulesPath,
ImmutableArray<string> workspaceMembers)
ImmutableArray<string> workspaceMembers,
ImmutableArray<GoModParser.GoModReplace> workspaceReplaces = default)
{
RootPath = rootPath;
GoModPath = goModPath;
@@ -26,6 +27,7 @@ internal static class GoProjectDiscoverer
GoWorkPath = goWorkPath;
VendorModulesPath = vendorModulesPath;
WorkspaceMembers = workspaceMembers;
WorkspaceReplaces = workspaceReplaces.IsDefault ? ImmutableArray<GoModParser.GoModReplace>.Empty : workspaceReplaces;
}
public string RootPath { get; }
@@ -35,11 +37,18 @@ internal static class GoProjectDiscoverer
public string? VendorModulesPath { get; }
public ImmutableArray<string> WorkspaceMembers { get; }
/// <summary>
/// Workspace-wide replace directives from go.work (applies to all member modules).
/// Module-level replaces take precedence over these when both specify the same module.
/// </summary>
public ImmutableArray<GoModParser.GoModReplace> WorkspaceReplaces { 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;
public bool HasWorkspaceReplaces => WorkspaceReplaces.Length > 0;
}
/// <summary>
@@ -160,7 +169,8 @@ internal static class GoProjectDiscoverer
File.Exists(rootGoSum) ? rootGoSum : null,
goWorkPath,
File.Exists(vendorModules) ? vendorModules : null,
workspaceMembers.ToImmutableArray());
workspaceMembers.ToImmutableArray(),
workData.Replaces);
}
private static GoProject? DiscoverStandaloneProject(string projectDir)

View File

@@ -56,6 +56,7 @@ internal static class GoSourceInventory
ImmutableArray<string>.Empty,
GoVersionConflictDetector.GoConflictAnalysis.Empty,
GoCgoDetector.CgoAnalysisResult.Empty,
ImmutableArray<GoCapabilityEvidence>.Empty,
null);
public SourceInventoryResult(
@@ -65,6 +66,7 @@ internal static class GoSourceInventory
ImmutableArray<string> retractedVersions,
GoVersionConflictDetector.GoConflictAnalysis conflictAnalysis,
GoCgoDetector.CgoAnalysisResult cgoAnalysis,
ImmutableArray<GoCapabilityEvidence> capabilities,
string? license)
{
ModulePath = modulePath;
@@ -73,12 +75,20 @@ internal static class GoSourceInventory
RetractedVersions = retractedVersions;
ConflictAnalysis = conflictAnalysis;
CgoAnalysis = cgoAnalysis;
Capabilities = capabilities;
License = license;
}
public string? ModulePath { get; }
public string? GoVersion { get; }
public ImmutableArray<GoSourceModule> Modules { get; }
/// <summary>
/// Versions of THIS module (the declaring module) that are retracted.
/// Note: These are versions of the main module itself, NOT dependency versions.
/// Go's `retract` directive only applies to the declaring module; we cannot know
/// offline if a dependency's version is retracted.
/// </summary>
public ImmutableArray<string> RetractedVersions { get; }
/// <summary>
@@ -91,12 +101,27 @@ internal static class GoSourceInventory
/// </summary>
public GoCgoDetector.CgoAnalysisResult CgoAnalysis { get; }
/// <summary>
/// Security-relevant capabilities detected in source code.
/// </summary>
public ImmutableArray<GoCapabilityEvidence> Capabilities { get; }
/// <summary>
/// Main module license (SPDX identifier).
/// </summary>
public string? License { get; }
public bool IsEmpty => Modules.IsEmpty && string.IsNullOrEmpty(ModulePath);
/// <summary>
/// Returns true if any critical-risk capabilities were detected.
/// </summary>
public bool HasCriticalCapabilities => Capabilities.Any(c => c.Risk == CapabilityRisk.Critical);
/// <summary>
/// Returns true if any high-risk capabilities were detected.
/// </summary>
public bool HasHighRiskCapabilities => Capabilities.Any(c => c.Risk >= CapabilityRisk.High);
}
/// <summary>
@@ -128,12 +153,24 @@ internal static class GoSourceInventory
? 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 replacement map: workspace-level replaces first, then module-level (module takes precedence)
var replacementBuilder = new Dictionary<string, GoModParser.GoModReplace>(StringComparer.Ordinal);
// Add workspace-level replaces first (from go.work)
foreach (var r in project.WorkspaceReplaces)
{
var key = r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath;
replacementBuilder[key] = r;
}
// Add module-level replaces (overrides workspace-level for same key)
foreach (var r in goMod.Replaces)
{
var key = r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath;
replacementBuilder[key] = r;
}
var replacements = replacementBuilder.ToImmutableDictionary(StringComparer.Ordinal);
// Build exclude set
var excludes = goMod.Excludes
@@ -267,6 +304,9 @@ internal static class GoSourceInventory
// Analyze CGO usage in the module
var cgoAnalysis = GoCgoDetector.AnalyzeModule(project.RootPath);
// Scan for security-relevant capabilities in source files
var capabilities = ScanCapabilities(project.RootPath);
// Detect main module license
var mainLicense = GoLicenseDetector.DetectLicense(project.RootPath);
@@ -277,9 +317,60 @@ internal static class GoSourceInventory
retractedVersions,
conflictAnalysis,
cgoAnalysis,
capabilities,
mainLicense.SpdxIdentifier);
}
/// <summary>
/// Scans Go source files for security-relevant capabilities.
/// </summary>
private static ImmutableArray<GoCapabilityEvidence> ScanCapabilities(string rootPath)
{
var capabilities = new List<GoCapabilityEvidence>();
try
{
var enumeration = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 10
};
foreach (var goFile in Directory.EnumerateFiles(rootPath, "*.go", enumeration))
{
// Skip vendor and testdata directories
if (goFile.Contains($"{Path.DirectorySeparatorChar}vendor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) ||
goFile.Contains($"{Path.DirectorySeparatorChar}testdata{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
var content = File.ReadAllText(goFile);
var relativePath = Path.GetRelativePath(rootPath, goFile);
var fileCapabilities = GoCapabilityScanner.ScanFile(content, relativePath);
capabilities.AddRange(fileCapabilities);
}
catch (IOException)
{
// Skip files that can't be read
}
catch (UnauthorizedAccessException)
{
// Skip files without read access
}
}
}
catch (UnauthorizedAccessException)
{
// Skip if directory access denied
}
return capabilities.ToImmutableArray();
}
/// <summary>
/// Builds combined inventory for a workspace (all members).
/// </summary>
@@ -301,7 +392,7 @@ internal static class GoSourceInventory
}
}
// Build inventory for each workspace member
// Build inventory for each workspace member, propagating workspace-level replaces
foreach (var memberPath in workspaceProject.WorkspaceMembers)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -311,13 +402,15 @@ internal static class GoSourceInventory
var memberGoSum = Path.Combine(memberFullPath, "go.sum");
var memberVendor = Path.Combine(memberFullPath, "vendor", "modules.txt");
// Create member project with workspace-level replaces inherited from parent
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);
ImmutableArray<string>.Empty,
workspaceProject.WorkspaceReplaces);
if (memberProject.HasGoMod)
{

View File

@@ -189,17 +189,13 @@ internal static partial class GoVersionConflictDetector
"Required version is explicitly excluded"));
}
// Check for retracted versions (in own module's go.mod)
if (module.IsRetracted || retractedVersions.Contains(module.Version))
{
conflicts.Add(new GoVersionConflict(
module.Path,
module.Version,
[module.Version],
GoConflictSeverity.High,
GoConflictType.RetractedVersion,
"Using a retracted version - may have known issues"));
}
// Note: `retract` directives apply ONLY to the declaring module, not dependencies.
// We cannot know if a dependency version is retracted without fetching that module's go.mod,
// which is not offline-compatible. The `retractedVersions` parameter contains versions of the
// main/declaring module that are retracted (for metadata purposes), NOT dependency retraction.
// Therefore, we do NOT check `retractedVersions.Contains(module.Version)` here - that would
// be a false positive. The `module.IsRetracted` flag should only be set if we have explicit
// evidence of retraction for THIS specific module (currently not implemented).
}
// Check for major version mismatches