Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,31 @@
# StellaOps.Scanner.Analyzers.Lang.Go — Agent Charter
## Role
Build the Go analyzer plug-in that reads Go build info, module metadata, and DWARF notes to attribute binaries with rich provenance inside Scanner.
## Scope
- Inspect binaries for build info (`.note.go.buildid`, Go build info blob) and extract module, version, VCS metadata.
- Parse DWARF-lite sections for commit hash / dirty flag and map to components.
- Manage shared hash cache to dedupe identical binaries across layers.
- Provide benchmarks and determinism fixtures; package plug-in manifest.
## Out of Scope
- Native library link analysis (belongs to native analyzer).
- VCS remote fetching or symbol download.
- Policy decisions or vulnerability joins.
## Expectations
- Latency targets: ≤400µs (hot) / ≤2ms (cold) per binary; minimal allocations via buffer pooling.
- Shared buffer pooling via `ArrayPool<byte>` for build-info/DWARF reads; safe for concurrent scans.
- Deterministic fallback to `bin:{sha256}` when metadata absent; heuristics clearly identified.
- Offline-first: rely solely on embedded metadata.
- Telemetry for binaries processed, metadata coverage, heuristics usage.
- Heuristic fallback metrics: `scanner_analyzer_golang_heuristic_total{indicator,version_hint}` increments whenever stripped binaries are classified via fallbacks.
## Dependencies
- Shared language analyzer core; Worker dispatcher; caching infrastructure (layer cache + file CAS).
## Testing & Artifacts
- Golden fixtures for modules with/without VCS info, stripped binaries, cross-compiled variants.
- Benchmark comparison with competitor scanners to demonstrate speed/fidelity advantages (captured in `src/Bench/StellaOps.Bench/Scanner.Analyzers/lang/go/`).
- ADR documenting heuristics and risk mitigation.

View File

@@ -0,0 +1,7 @@
global using System;
global using System.Collections.Generic;
global using System.IO;
global using System.Threading;
global using System.Threading.Tasks;
global using StellaOps.Scanner.Analyzers.Lang;

View File

@@ -0,0 +1,17 @@
using System;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
namespace StellaOps.Scanner.Analyzers.Lang.Go;
public sealed class GoAnalyzerPlugin : ILanguageAnalyzerPlugin
{
public string Name => "StellaOps.Scanner.Analyzers.Lang.Go";
public bool IsAvailable(IServiceProvider services) => services is not null;
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
return new GoLanguageAnalyzer();
}
}

View File

@@ -0,0 +1,385 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Linq;
using StellaOps.Scanner.Analyzers.Lang.Go.Internal;
namespace StellaOps.Scanner.Analyzers.Lang.Go;
public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
{
public string Id => "golang";
public string DisplayName => "Go Analyzer";
public ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(writer);
var candidatePaths = new List<string>(GoBinaryScanner.EnumerateCandidateFiles(context.RootPath));
candidatePaths.Sort(StringComparer.Ordinal);
var fallbackBinaries = new List<GoStrippedBinaryClassification>();
foreach (var absolutePath in candidatePaths)
{
cancellationToken.ThrowIfCancellationRequested();
if (!GoBuildInfoProvider.TryGetBuildInfo(absolutePath, out var buildInfo) || buildInfo is null)
{
if (GoBinaryScanner.TryClassifyStrippedBinary(absolutePath, out var classification))
{
fallbackBinaries.Add(classification);
}
continue;
}
EmitComponents(buildInfo, context, writer);
}
foreach (var fallback in fallbackBinaries)
{
cancellationToken.ThrowIfCancellationRequested();
EmitFallbackComponent(fallback, context, writer);
}
return ValueTask.CompletedTask;
}
private void EmitComponents(GoBuildInfo buildInfo, LanguageAnalyzerContext context, LanguageComponentWriter writer)
{
var components = new List<GoModule> { buildInfo.MainModule };
components.AddRange(buildInfo.Dependencies
.OrderBy(static module => module.Path, StringComparer.Ordinal)
.ThenBy(static module => module.Version, StringComparer.Ordinal));
string? binaryHash = null;
var binaryRelativePath = context.GetRelativePath(buildInfo.AbsoluteBinaryPath);
foreach (var module in components)
{
var metadata = BuildMetadata(buildInfo, module, binaryRelativePath);
var evidence = BuildEvidence(buildInfo, module, binaryRelativePath, context, ref binaryHash);
var usedByEntrypoint = module.IsMain && context.UsageHints.IsPathUsed(buildInfo.AbsoluteBinaryPath);
var purl = BuildPurl(module.Path, module.Version);
if (!string.IsNullOrEmpty(purl))
{
writer.AddFromPurl(
analyzerId: Id,
purl: purl,
name: module.Path,
version: module.Version,
type: "golang",
metadata: metadata,
evidence: evidence,
usedByEntrypoint: usedByEntrypoint);
}
else
{
var componentKey = BuildFallbackComponentKey(module, buildInfo, binaryRelativePath, ref binaryHash);
writer.AddFromExplicitKey(
analyzerId: Id,
componentKey: componentKey,
purl: null,
name: module.Path,
version: module.Version,
type: "golang",
metadata: metadata,
evidence: evidence,
usedByEntrypoint: usedByEntrypoint);
}
}
}
private static IEnumerable<KeyValuePair<string, string?>> BuildMetadata(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath)
{
var entries = new List<KeyValuePair<string, string?>>(16)
{
new("modulePath", module.Path),
new("binaryPath", string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath),
};
if (!string.IsNullOrEmpty(module.Version))
{
entries.Add(new KeyValuePair<string, string?>("moduleVersion", module.Version));
}
if (!string.IsNullOrEmpty(module.Sum))
{
entries.Add(new KeyValuePair<string, string?>("moduleSum", module.Sum));
}
if (module.Replacement is not null)
{
entries.Add(new KeyValuePair<string, string?>("replacedBy.path", module.Replacement.Path));
if (!string.IsNullOrEmpty(module.Replacement.Version))
{
entries.Add(new KeyValuePair<string, string?>("replacedBy.version", module.Replacement.Version));
}
if (!string.IsNullOrEmpty(module.Replacement.Sum))
{
entries.Add(new KeyValuePair<string, string?>("replacedBy.sum", module.Replacement.Sum));
}
}
if (module.IsMain)
{
entries.Add(new KeyValuePair<string, string?>("go.version", buildInfo.GoVersion));
entries.Add(new KeyValuePair<string, string?>("modulePath.main", buildInfo.ModulePath));
foreach (var setting in buildInfo.Settings)
{
var key = $"build.{setting.Key}";
if (!entries.Any(pair => string.Equals(pair.Key, key, StringComparison.Ordinal)))
{
entries.Add(new KeyValuePair<string, string?>(key, setting.Value));
}
}
if (buildInfo.DwarfMetadata is { } dwarf)
{
AddIfMissing(entries, "build.vcs", dwarf.VcsSystem);
AddIfMissing(entries, "build.vcs.revision", dwarf.Revision);
AddIfMissing(entries, "build.vcs.modified", dwarf.Modified?.ToString()?.ToLowerInvariant());
AddIfMissing(entries, "build.vcs.time", dwarf.TimestampUtc);
}
}
entries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
return entries;
}
private void EmitFallbackComponent(GoStrippedBinaryClassification strippedBinary, LanguageAnalyzerContext context, LanguageComponentWriter writer)
{
var relativePath = context.GetRelativePath(strippedBinary.AbsolutePath);
var normalizedRelative = string.IsNullOrEmpty(relativePath) ? "." : relativePath;
var usedByEntrypoint = context.UsageHints.IsPathUsed(strippedBinary.AbsolutePath);
var binaryHash = ComputeBinaryHash(strippedBinary.AbsolutePath);
var metadata = new List<KeyValuePair<string, string?>>
{
new("binaryPath", normalizedRelative),
new("languageHint", "golang"),
new("provenance", "binary"),
};
if (!string.IsNullOrEmpty(binaryHash))
{
metadata.Add(new KeyValuePair<string, string?>("binary.sha256", binaryHash));
}
if (!string.IsNullOrEmpty(strippedBinary.GoVersionHint))
{
metadata.Add(new KeyValuePair<string, string?>("go.version.hint", strippedBinary.GoVersionHint));
}
metadata.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));
var evidence = new List<LanguageComponentEvidence>
{
new(
LanguageEvidenceKind.File,
"binary",
normalizedRelative,
null,
string.IsNullOrEmpty(binaryHash) ? null : binaryHash),
};
var detectionSource = strippedBinary.Indicator switch
{
GoStrippedBinaryIndicator.BuildId => "build-id",
GoStrippedBinaryIndicator.GoRuntimeMarkers => "runtime-markers",
_ => null,
};
if (!string.IsNullOrEmpty(detectionSource))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.heuristic",
"classification",
detectionSource,
null));
}
evidence.Sort(static (left, right) => string.CompareOrdinal(left.ComparisonKey, right.ComparisonKey));
var componentName = Path.GetFileName(strippedBinary.AbsolutePath);
if (string.IsNullOrWhiteSpace(componentName))
{
componentName = "golang-binary";
}
var componentKey = string.IsNullOrEmpty(binaryHash)
? $"golang::bin::{normalizedRelative}"
: $"golang::bin::sha256:{binaryHash}";
writer.AddFromExplicitKey(
analyzerId: Id,
componentKey: componentKey,
purl: null,
name: componentName,
version: null,
type: "bin",
metadata: metadata,
evidence: evidence,
usedByEntrypoint: usedByEntrypoint);
GoAnalyzerMetrics.RecordHeuristic(strippedBinary.Indicator, !string.IsNullOrEmpty(strippedBinary.GoVersionHint));
}
private static IEnumerable<LanguageComponentEvidence> BuildEvidence(GoBuildInfo buildInfo, GoModule module, string binaryRelativePath, LanguageAnalyzerContext context, ref string? binaryHash)
{
var evidence = new List<LanguageComponentEvidence>
{
new(
LanguageEvidenceKind.Metadata,
"go.buildinfo",
$"module:{module.Path}",
module.Version ?? string.Empty,
module.Sum)
};
if (module.IsMain)
{
foreach (var setting in buildInfo.Settings)
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.buildinfo.setting",
setting.Key,
setting.Value,
null));
}
if (buildInfo.DwarfMetadata is { } dwarf)
{
if (!string.IsNullOrWhiteSpace(dwarf.VcsSystem))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.dwarf",
"vcs",
dwarf.VcsSystem,
null));
}
if (!string.IsNullOrWhiteSpace(dwarf.Revision))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.dwarf",
"vcs.revision",
dwarf.Revision,
null));
}
if (dwarf.Modified.HasValue)
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.dwarf",
"vcs.modified",
dwarf.Modified.Value ? "true" : "false",
null));
}
if (!string.IsNullOrWhiteSpace(dwarf.TimestampUtc))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Metadata,
"go.dwarf",
"vcs.time",
dwarf.TimestampUtc,
null));
}
}
}
// Attach binary hash evidence for fallback components without purl.
if (string.IsNullOrEmpty(module.Version))
{
binaryHash ??= ComputeBinaryHash(buildInfo.AbsoluteBinaryPath);
if (!string.IsNullOrEmpty(binaryHash))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"binary",
string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath,
null,
binaryHash));
}
}
evidence.Sort(static (left, right) => string.CompareOrdinal(left.ComparisonKey, right.ComparisonKey));
return evidence;
}
private static string? BuildPurl(string path, string? version)
{
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(version))
{
return null;
}
var cleanedPath = path.Trim();
var cleanedVersion = version.Trim();
var encodedVersion = Uri.EscapeDataString(cleanedVersion);
return $"pkg:golang/{cleanedPath}@{encodedVersion}";
}
private static string BuildFallbackComponentKey(GoModule module, GoBuildInfo buildInfo, string binaryRelativePath, ref string? binaryHash)
{
var relative = string.IsNullOrEmpty(binaryRelativePath) ? "." : binaryRelativePath;
binaryHash ??= ComputeBinaryHash(buildInfo.AbsoluteBinaryPath);
if (!string.IsNullOrEmpty(binaryHash))
{
return $"golang::module:{module.Path}::{relative}::{binaryHash}";
}
return $"golang::module:{module.Path}::{relative}";
}
private static void AddIfMissing(List<KeyValuePair<string, string?>> entries, string key, string? value)
{
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
{
return;
}
if (entries.Any(entry => string.Equals(entry.Key, key, StringComparison.Ordinal)))
{
return;
}
entries.Add(new KeyValuePair<string, string?>(key, value));
}
private static string? ComputeBinaryHash(string path)
{
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
catch (IOException)
{
return null;
}
catch (UnauthorizedAccessException)
{
return null;
}
}
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoAnalyzerMetrics
{
private static readonly Meter Meter = new("StellaOps.Scanner.Analyzers.Lang.Go", "1.0.0");
private static readonly Counter<long> HeuristicCounter = Meter.CreateCounter<long>(
"scanner_analyzer_golang_heuristic_total",
unit: "components",
description: "Counts Go components emitted via heuristic fallbacks when build metadata is missing.");
public static void RecordHeuristic(GoStrippedBinaryIndicator indicator, bool hasVersionHint)
{
HeuristicCounter.Add(
1,
new KeyValuePair<string, object?>("indicator", NormalizeIndicator(indicator)),
new KeyValuePair<string, object?>("version_hint", hasVersionHint ? "present" : "absent"));
}
private static string NormalizeIndicator(GoStrippedBinaryIndicator indicator)
=> indicator switch
{
GoStrippedBinaryIndicator.BuildId => "build-id",
GoStrippedBinaryIndicator.GoRuntimeMarkers => "runtime-markers",
_ => "unknown",
};
}

View File

@@ -0,0 +1,264 @@
using System;
using System.Collections.Generic;
using System.Buffers;
using System.IO;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBinaryScanner
{
private static readonly ReadOnlyMemory<byte> BuildInfoMagic = new byte[]
{
0xFF, (byte)' ', (byte)'G', (byte)'o', (byte)' ', (byte)'b', (byte)'u', (byte)'i', (byte)'l', (byte)'d', (byte)'i', (byte)'n', (byte)'f', (byte)':'
};
private static readonly ReadOnlyMemory<byte> BuildIdMarker = Encoding.ASCII.GetBytes("Go build ID:");
private static readonly ReadOnlyMemory<byte> GoPclnTabMarker = Encoding.ASCII.GetBytes(".gopclntab");
private static readonly ReadOnlyMemory<byte> GoVersionPrefix = Encoding.ASCII.GetBytes("go1.");
public static IEnumerable<string> EnumerateCandidateFiles(string rootPath)
{
var enumeration = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Device | FileAttributes.ReparsePoint,
MatchCasing = MatchCasing.CaseSensitive,
};
foreach (var path in Directory.EnumerateFiles(rootPath, "*", enumeration))
{
yield return path;
}
}
public static bool TryReadBuildInfo(string filePath, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
FileInfo info;
try
{
info = new FileInfo(filePath);
if (!info.Exists || info.Length < 64 || info.Length > 128 * 1024 * 1024)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var length = info.Length;
if (length <= 0)
{
return false;
}
var inspectLength = (int)Math.Min(length, int.MaxValue);
var buffer = ArrayPool<byte>.Shared.Rent(inspectLength);
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var totalRead = 0;
while (totalRead < inspectLength)
{
var read = stream.Read(buffer, totalRead, inspectLength - totalRead);
if (read <= 0)
{
break;
}
totalRead += read;
}
if (totalRead < 64)
{
return false;
}
var span = new ReadOnlySpan<byte>(buffer, 0, totalRead);
var offset = span.IndexOf(BuildInfoMagic.Span);
if (offset < 0)
{
return false;
}
var view = span[offset..];
return GoBuildInfoDecoder.TryDecode(view, out goVersion, out moduleData);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
finally
{
Array.Clear(buffer, 0, inspectLength);
ArrayPool<byte>.Shared.Return(buffer);
}
}
public static bool TryClassifyStrippedBinary(string filePath, out GoStrippedBinaryClassification classification)
{
classification = default;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var length = fileInfo.Length;
if (length < 128)
{
return false;
}
const int WindowSize = 128 * 1024;
var readSize = (int)Math.Min(length, WindowSize);
var buffer = ArrayPool<byte>.Shared.Rent(readSize);
try
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var headRead = stream.Read(buffer, 0, readSize);
if (headRead <= 0)
{
return false;
}
var headSpan = new ReadOnlySpan<byte>(buffer, 0, headRead);
var hasBuildId = headSpan.IndexOf(BuildIdMarker.Span) >= 0;
var hasPcln = headSpan.IndexOf(GoPclnTabMarker.Span) >= 0;
var goVersion = ExtractGoVersion(headSpan);
if (length > headRead)
{
var tailSize = Math.Min(readSize, (int)length);
if (tailSize > 0)
{
stream.Seek(-tailSize, SeekOrigin.End);
var tailRead = stream.Read(buffer, 0, tailSize);
if (tailRead > 0)
{
var tailSpan = new ReadOnlySpan<byte>(buffer, 0, tailRead);
hasBuildId |= tailSpan.IndexOf(BuildIdMarker.Span) >= 0;
hasPcln |= tailSpan.IndexOf(GoPclnTabMarker.Span) >= 0;
goVersion ??= ExtractGoVersion(tailSpan);
}
}
}
if (hasBuildId)
{
classification = new GoStrippedBinaryClassification(
filePath,
GoStrippedBinaryIndicator.BuildId,
goVersion);
return true;
}
if (hasPcln && !string.IsNullOrEmpty(goVersion))
{
classification = new GoStrippedBinaryClassification(
filePath,
GoStrippedBinaryIndicator.GoRuntimeMarkers,
goVersion);
return true;
}
return false;
}
finally
{
Array.Clear(buffer, 0, readSize);
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string? ExtractGoVersion(ReadOnlySpan<byte> data)
{
var prefix = GoVersionPrefix.Span;
var span = data;
while (!span.IsEmpty)
{
var index = span.IndexOf(prefix);
if (index < 0)
{
return null;
}
var absoluteIndex = data.Length - span.Length + index;
if (absoluteIndex > 0)
{
var previous = (char)data[absoluteIndex - 1];
if (char.IsLetterOrDigit(previous))
{
span = span[(index + 1)..];
continue;
}
}
var start = absoluteIndex;
var end = start + prefix.Length;
while (end < data.Length && IsVersionCharacter((char)data[end]))
{
end++;
}
if (end - start <= prefix.Length)
{
span = span[(index + 1)..];
continue;
}
var candidate = data[start..end];
return Encoding.ASCII.GetString(candidate);
}
return null;
}
private static bool IsVersionCharacter(char value)
=> (value >= '0' && value <= '9')
|| (value >= 'a' && value <= 'z')
|| (value >= 'A' && value <= 'Z')
|| value is '.' or '-' or '+' or '_';
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoBuildInfo
{
public GoBuildInfo(
string goVersion,
string absoluteBinaryPath,
string modulePath,
GoModule mainModule,
IEnumerable<GoModule> dependencies,
IEnumerable<KeyValuePair<string, string?>> settings,
GoDwarfMetadata? dwarfMetadata = null)
: this(
goVersion,
absoluteBinaryPath,
modulePath,
mainModule,
dependencies?
.Where(static module => module is not null)
.ToImmutableArray()
?? ImmutableArray<GoModule>.Empty,
settings?
.Where(static pair => pair.Key is not null)
.Select(static pair => new KeyValuePair<string, string?>(pair.Key, pair.Value))
.ToImmutableArray()
?? ImmutableArray<KeyValuePair<string, string?>>.Empty,
dwarfMetadata)
{
}
private GoBuildInfo(
string goVersion,
string absoluteBinaryPath,
string modulePath,
GoModule mainModule,
ImmutableArray<GoModule> dependencies,
ImmutableArray<KeyValuePair<string, string?>> settings,
GoDwarfMetadata? dwarfMetadata)
{
GoVersion = goVersion ?? throw new ArgumentNullException(nameof(goVersion));
AbsoluteBinaryPath = absoluteBinaryPath ?? throw new ArgumentNullException(nameof(absoluteBinaryPath));
ModulePath = modulePath ?? throw new ArgumentNullException(nameof(modulePath));
MainModule = mainModule ?? throw new ArgumentNullException(nameof(mainModule));
Dependencies = dependencies;
Settings = settings;
DwarfMetadata = dwarfMetadata;
}
public string GoVersion { get; }
public string AbsoluteBinaryPath { get; }
public string ModulePath { get; }
public GoModule MainModule { get; }
public ImmutableArray<GoModule> Dependencies { get; }
public ImmutableArray<KeyValuePair<string, string?>> Settings { get; }
public GoDwarfMetadata? DwarfMetadata { get; }
public GoBuildInfo WithDwarf(GoDwarfMetadata metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
return new GoBuildInfo(
GoVersion,
AbsoluteBinaryPath,
ModulePath,
MainModule,
Dependencies,
Settings,
metadata);
}
}

View File

@@ -0,0 +1,159 @@
using System;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoDecoder
{
private const string BuildInfoMagic = "\xff Go buildinf:";
private const int HeaderSize = 32;
private const byte VarintEncodingFlag = 0x02;
public static bool TryDecode(ReadOnlySpan<byte> data, out string? goVersion, out string? moduleData)
{
goVersion = null;
moduleData = null;
if (data.Length < HeaderSize)
{
return false;
}
if (!IsMagicMatch(data))
{
return false;
}
var pointerSize = data[14];
var flags = data[15];
if (pointerSize != 4 && pointerSize != 8)
{
return false;
}
if ((flags & VarintEncodingFlag) == 0)
{
// Older Go toolchains encode pointers to strings instead of inline data.
// The Sprint 10 scope targets Go 1.18+, which always sets the varint flag.
return false;
}
var payload = data.Slice(HeaderSize);
if (!TryReadVarString(payload, out var version, out var consumed))
{
return false;
}
payload = payload.Slice(consumed);
if (!TryReadVarString(payload, out var modules, out _))
{
return false;
}
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
modules = StripSentinel(modules);
goVersion = version;
moduleData = modules;
return !string.IsNullOrWhiteSpace(moduleData);
}
private static bool IsMagicMatch(ReadOnlySpan<byte> data)
{
if (data.Length < BuildInfoMagic.Length)
{
return false;
}
for (var i = 0; i < BuildInfoMagic.Length; i++)
{
if (data[i] != BuildInfoMagic[i])
{
return false;
}
}
return true;
}
private static bool TryReadVarString(ReadOnlySpan<byte> data, out string result, out int consumed)
{
result = string.Empty;
consumed = 0;
if (!TryReadUVarint(data, out var length, out var lengthBytes))
{
return false;
}
if (length > int.MaxValue)
{
return false;
}
var stringLength = (int)length;
var totalRequired = lengthBytes + stringLength;
if (stringLength <= 0 || totalRequired > data.Length)
{
return false;
}
var slice = data.Slice(lengthBytes, stringLength);
result = Encoding.UTF8.GetString(slice);
consumed = totalRequired;
return true;
}
private static bool TryReadUVarint(ReadOnlySpan<byte> data, out ulong value, out int bytesRead)
{
value = 0;
bytesRead = 0;
ulong x = 0;
var shift = 0;
for (var i = 0; i < data.Length; i++)
{
var b = data[i];
if (b < 0x80)
{
if (i > 9 || i == 9 && b > 1)
{
return false;
}
value = x | (ulong)b << shift;
bytesRead = i + 1;
return true;
}
x |= (ulong)(b & 0x7F) << shift;
shift += 7;
}
return false;
}
private static string StripSentinel(string value)
{
if (string.IsNullOrEmpty(value) || value.Length < 33)
{
return value;
}
var sentinelIndex = value.Length - 17;
if (value[sentinelIndex] != '\n')
{
return value;
}
return value[16..^16];
}
}

View File

@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoParser
{
private const string PathPrefix = "path\t";
private const string ModulePrefix = "mod\t";
private const string DependencyPrefix = "dep\t";
private const string ReplacementPrefix = "=>\t";
private const string BuildPrefix = "build\t";
public static bool TryParse(string goVersion, string absoluteBinaryPath, string rawModuleData, out GoBuildInfo? info)
{
info = null;
if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(rawModuleData))
{
return false;
}
string? modulePath = null;
GoModule? mainModule = null;
var dependencies = new List<GoModule>();
var settings = new SortedDictionary<string, string?>(StringComparer.Ordinal);
GoModule? lastModule = null;
using var reader = new StringReader(rawModuleData);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (line.StartsWith(PathPrefix, StringComparison.Ordinal))
{
modulePath = line[PathPrefix.Length..].Trim();
continue;
}
if (line.StartsWith(ModulePrefix, StringComparison.Ordinal))
{
mainModule = ParseModule(line.AsSpan(ModulePrefix.Length), isMain: true);
lastModule = mainModule;
continue;
}
if (line.StartsWith(DependencyPrefix, StringComparison.Ordinal))
{
var dependency = ParseModule(line.AsSpan(DependencyPrefix.Length), isMain: false);
if (dependency is not null)
{
dependencies.Add(dependency);
lastModule = dependency;
}
continue;
}
if (line.StartsWith(ReplacementPrefix, StringComparison.Ordinal))
{
if (lastModule is null)
{
continue;
}
var replacement = ParseReplacement(line.AsSpan(ReplacementPrefix.Length));
if (replacement is not null)
{
lastModule.SetReplacement(replacement);
}
continue;
}
if (line.StartsWith(BuildPrefix, StringComparison.Ordinal))
{
var pair = ParseBuildSetting(line.AsSpan(BuildPrefix.Length));
if (!string.IsNullOrEmpty(pair.Key))
{
settings[pair.Key] = pair.Value;
}
}
}
if (mainModule is null)
{
return false;
}
if (string.IsNullOrEmpty(modulePath))
{
modulePath = mainModule.Path;
}
info = new GoBuildInfo(
goVersion,
absoluteBinaryPath,
modulePath,
mainModule,
dependencies,
settings);
return true;
}
private static GoModule? ParseModule(ReadOnlySpan<char> span, bool isMain)
{
var fields = SplitFields(span, expected: 4);
if (fields.Count == 0)
{
return null;
}
var path = fields[0];
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var version = fields.Count > 1 ? fields[1] : null;
var sum = fields.Count > 2 ? fields[2] : null;
return new GoModule(path, version, sum, isMain);
}
private static GoModuleReplacement? ParseReplacement(ReadOnlySpan<char> span)
{
var fields = SplitFields(span, expected: 3);
if (fields.Count == 0)
{
return null;
}
var path = fields[0];
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var version = fields.Count > 1 ? fields[1] : null;
var sum = fields.Count > 2 ? fields[2] : null;
return new GoModuleReplacement(path, version, sum);
}
private static KeyValuePair<string, string?> ParseBuildSetting(ReadOnlySpan<char> span)
{
span = span.Trim();
if (span.IsEmpty)
{
return default;
}
var separatorIndex = span.IndexOf('=');
if (separatorIndex <= 0)
{
return default;
}
var rawKey = span[..separatorIndex].Trim();
var rawValue = span[(separatorIndex + 1)..].Trim();
var key = Unquote(rawKey.ToString());
if (string.IsNullOrWhiteSpace(key))
{
return default;
}
var value = Unquote(rawValue.ToString());
return new KeyValuePair<string, string?>(key, value);
}
private static List<string> SplitFields(ReadOnlySpan<char> span, int expected)
{
var fields = new List<string>(expected);
var builder = new StringBuilder();
for (var i = 0; i < span.Length; i++)
{
var current = span[i];
if (current == '\t')
{
fields.Add(builder.ToString());
builder.Clear();
continue;
}
builder.Append(current);
}
fields.Add(builder.ToString());
return fields;
}
private static string Unquote(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
value = value.Trim();
if (value.Length < 2)
{
return value;
}
if (value[0] == '"' && value[^1] == '"')
{
try
{
return JsonSerializer.Deserialize<string>(value) ?? value;
}
catch (JsonException)
{
return value;
}
}
if (value[0] == '`' && value[^1] == '`')
{
return value[1..^1];
}
return value;
}
}

View File

@@ -0,0 +1,82 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Security;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoBuildInfoProvider
{
private static readonly ConcurrentDictionary<GoBinaryCacheKey, GoBuildInfo?> Cache = new();
public static bool TryGetBuildInfo(string absolutePath, out GoBuildInfo? info)
{
info = null;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(absolutePath);
if (!fileInfo.Exists)
{
return false;
}
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
catch (System.Security.SecurityException)
{
return false;
}
var key = new GoBinaryCacheKey(absolutePath, fileInfo.Length, fileInfo.LastWriteTimeUtc.Ticks);
info = Cache.GetOrAdd(key, static (cacheKey, path) => CreateBuildInfo(path), absolutePath);
return info is not null;
}
private static GoBuildInfo? CreateBuildInfo(string absolutePath)
{
if (!GoBinaryScanner.TryReadBuildInfo(absolutePath, out var goVersion, out var moduleData))
{
return null;
}
if (string.IsNullOrWhiteSpace(goVersion) || string.IsNullOrWhiteSpace(moduleData))
{
return null;
}
if (!GoBuildInfoParser.TryParse(goVersion!, absolutePath, moduleData!, out var buildInfo) || buildInfo is null)
{
return null;
}
if (GoDwarfReader.TryRead(absolutePath, out var dwarf) && dwarf is not null)
{
buildInfo = buildInfo.WithDwarf(dwarf);
}
return buildInfo;
}
private readonly record struct GoBinaryCacheKey(string Path, long Length, long LastWriteTicks)
{
private readonly string _normalizedPath = OperatingSystem.IsWindows()
? Path.ToLowerInvariant()
: Path;
public bool Equals(GoBinaryCacheKey other)
=> Length == other.Length
&& LastWriteTicks == other.LastWriteTicks
&& string.Equals(_normalizedPath, other._normalizedPath, StringComparison.Ordinal);
public override int GetHashCode()
=> HashCode.Combine(_normalizedPath, Length, LastWriteTicks);
}
}

View File

@@ -0,0 +1,33 @@
using System;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoDwarfMetadata
{
public GoDwarfMetadata(string? vcsSystem, string? revision, bool? modified, string? timestampUtc)
{
VcsSystem = Normalize(vcsSystem);
Revision = Normalize(revision);
Modified = modified;
TimestampUtc = Normalize(timestampUtc);
}
public string? VcsSystem { get; }
public string? Revision { get; }
public bool? Modified { get; }
public string? TimestampUtc { get; }
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}

View File

@@ -0,0 +1,120 @@
using System;
using System.Buffers;
using System.IO;
using System.Text;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal static class GoDwarfReader
{
private static readonly byte[] VcsSystemToken = Encoding.UTF8.GetBytes("vcs=");
private static readonly byte[] VcsRevisionToken = Encoding.UTF8.GetBytes("vcs.revision=");
private static readonly byte[] VcsModifiedToken = Encoding.UTF8.GetBytes("vcs.modified=");
private static readonly byte[] VcsTimeToken = Encoding.UTF8.GetBytes("vcs.time=");
public static bool TryRead(string path, out GoDwarfMetadata? metadata)
{
metadata = null;
FileInfo fileInfo;
try
{
fileInfo = new FileInfo(path);
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
if (!fileInfo.Exists || fileInfo.Length == 0 || fileInfo.Length > 256 * 1024 * 1024)
{
return false;
}
var length = fileInfo.Length;
var readLength = (int)Math.Min(length, int.MaxValue);
var buffer = ArrayPool<byte>.Shared.Rent(readLength);
var bytesRead = 0;
try
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
bytesRead = stream.Read(buffer, 0, readLength);
if (bytesRead <= 0)
{
return false;
}
var data = new ReadOnlySpan<byte>(buffer, 0, bytesRead);
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;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
finally
{
Array.Clear(buffer, 0, bytesRead);
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string? ExtractValue(ReadOnlySpan<byte> data, ReadOnlySpan<byte> token)
{
var index = data.IndexOf(token);
if (index < 0)
{
return null;
}
var start = index + token.Length;
var end = start;
while (end < data.Length)
{
var current = data[end];
if (current == 0 || current == (byte)'\n' || current == (byte)'\r')
{
break;
}
end++;
}
if (end <= start)
{
return null;
}
return Encoding.UTF8.GetString(data.Slice(start, end - start));
}
}

View File

@@ -0,0 +1,67 @@
using System;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal sealed class GoModule
{
public GoModule(string path, string? version, string? sum, bool isMain)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Version = Normalize(version);
Sum = Normalize(sum);
IsMain = isMain;
}
public string Path { get; }
public string? Version { get; }
public string? Sum { get; }
public GoModuleReplacement? Replacement { get; private set; }
public bool IsMain { get; }
public void SetReplacement(GoModuleReplacement replacement)
{
Replacement = replacement ?? throw new ArgumentNullException(nameof(replacement));
}
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}
internal sealed class GoModuleReplacement
{
public GoModuleReplacement(string path, string? version, string? sum)
{
Path = path ?? throw new ArgumentNullException(nameof(path));
Version = Normalize(version);
Sum = Normalize(sum);
}
public string Path { get; }
public string? Version { get; }
public string? Sum { get; }
private static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
internal readonly record struct GoStrippedBinaryClassification(
string AbsolutePath,
GoStrippedBinaryIndicator Indicator,
string? GoVersionHint);
internal enum GoStrippedBinaryIndicator
{
None = 0,
BuildId,
GoRuntimeMarkers,
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />
<None Include="**\\*" Exclude="**\\*.cs;**\\*.json;bin\\**;obj\\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Analyzers.Lang\StellaOps.Scanner.Analyzers.Lang.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
# Go Analyzer Task Flow
| Seq | ID | Status | Depends on | Description | Exit Criteria |
|-----|----|--------|------------|-------------|---------------|
| 1 | SCANNER-ANALYZERS-LANG-10-304A | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307 | Parse Go build info blob (`runtime/debug` format) and `.note.go.buildid`; map to module/version and evidence. | Build info extracted across Go 1.181.23 fixtures; evidence includes VCS, module path, and build settings. |
| 2 | SCANNER-ANALYZERS-LANG-10-304B | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304A | Implement DWARF-lite reader for VCS metadata + dirty flag; add cache to avoid re-reading identical binaries. | DWARF reader supplies commit hash for ≥95% fixtures; cache reduces duplicated IO by ≥70%. |
| 3 | SCANNER-ANALYZERS-LANG-10-304C | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304B | Fallback heuristics for stripped binaries with deterministic `bin:{sha256}` labeling and quiet provenance. | Heuristic labels clearly separated; tests ensure no false “observed” provenance; documentation updated. |
| 4 | SCANNER-ANALYZERS-LANG-10-307G | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304C | Wire shared helpers (license mapping, usage flags) and ensure concurrency-safe buffer reuse. | Analyzer reuses shared infrastructure; concurrency tests with parallel scans pass; no data races. |
| 5 | SCANNER-ANALYZERS-LANG-10-308G | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-307G | Determinism fixtures + benchmark harness (Vs competitor). | Fixtures under `Fixtures/lang/go/`; CI determinism check; benchmark runs showing ≥20% speed advantage. |
| 6 | SCANNER-ANALYZERS-LANG-10-309G | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-308G | Package plug-in manifest + Offline Kit notes; ensure Worker DI registration. | Manifest copied; Worker loads analyzer; Offline Kit docs updated with Go analyzer presence. |
| 7 | SCANNER-ANALYZERS-LANG-10-304D | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304C | Emit telemetry counters for stripped-binary heuristics and document metrics wiring. | New `scanner_analyzer_golang_heuristic_total` counter recorded; docs updated with offline aggregation notes. |
| 8 | SCANNER-ANALYZERS-LANG-10-304E | DONE (2025-10-22) | SCANNER-ANALYZERS-LANG-10-304D | Plumb Go heuristic counter into Scanner metrics pipeline and alerting. | Counter emitted through Worker telemetry/export pipeline; dashboard & alert rule documented; smoke test proves metric visibility. |

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": "1.0",
"id": "stellaops.analyzer.lang.go",
"displayName": "StellaOps Go Analyzer (preview)",
"version": "0.1.0",
"requiresRestart": true,
"entryPoint": {
"type": "dotnet",
"assembly": "StellaOps.Scanner.Analyzers.Lang.Go.dll",
"typeName": "StellaOps.Scanner.Analyzers.Lang.Go.GoAnalyzerPlugin"
},
"capabilities": [
"language-analyzer",
"golang",
"go"
],
"metadata": {
"org.stellaops.analyzer.language": "go",
"org.stellaops.analyzer.kind": "language",
"org.stellaops.restart.required": "true",
"org.stellaops.analyzer.status": "preview"
}
}