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