Add comprehensive tests for Go and Python version conflict detection and licensing normalization

- Implemented GoVersionConflictDetectorTests to validate pseudo-version detection, conflict analysis, and conflict retrieval for Go modules.
- Created VersionConflictDetectorTests for Python to assess conflict detection across various version scenarios, including major, minor, and patch differences.
- Added SpdxLicenseNormalizerTests to ensure accurate normalization of SPDX license strings and classifiers.
- Developed VendoredPackageDetectorTests to identify vendored packages and extract embedded packages from Python packages, including handling of vendor directories and known vendored packages.
This commit is contained in:
StellaOps Bot
2025-12-07 01:51:37 +02:00
parent 98934170ca
commit e0f6efecce
66 changed files with 7591 additions and 451 deletions

View File

@@ -175,6 +175,47 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
metadata["workspace"] = "true";
}
// Add license metadata
if (!string.IsNullOrEmpty(inventory.License))
{
metadata["license"] = inventory.License;
}
// Add CGO metadata
if (!inventory.CgoAnalysis.IsEmpty)
{
metadata["cgo.enabled"] = inventory.CgoAnalysis.HasCgoImport ? "true" : "false";
var cflags = inventory.CgoAnalysis.GetCFlags();
if (!string.IsNullOrEmpty(cflags))
{
metadata["cgo.cflags"] = cflags;
}
var ldflags = inventory.CgoAnalysis.GetLdFlags();
if (!string.IsNullOrEmpty(ldflags))
{
metadata["cgo.ldflags"] = ldflags;
}
if (inventory.CgoAnalysis.NativeLibraries.Length > 0)
{
metadata["cgo.nativeLibs"] = string.Join(",", inventory.CgoAnalysis.NativeLibraries.Take(10));
}
if (inventory.CgoAnalysis.IncludedHeaders.Length > 0)
{
metadata["cgo.headers"] = string.Join(",", inventory.CgoAnalysis.IncludedHeaders.Take(10));
}
}
// Add conflict summary for main module
if (inventory.ConflictAnalysis.HasConflicts)
{
metadata["conflict.count"] = inventory.ConflictAnalysis.Conflicts.Length.ToString();
metadata["conflict.maxSeverity"] = inventory.ConflictAnalysis.MaxSeverity.ToString().ToLowerInvariant();
}
var evidence = new List<LanguageComponentEvidence>();
if (!string.IsNullOrEmpty(goModRelative))
@@ -187,6 +228,17 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
null));
}
// Add CGO file evidence
foreach (var cgoFile in inventory.CgoAnalysis.CgoFiles.Take(5))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.File,
"cgo-source",
cgoFile,
"import \"C\"",
null));
}
evidence.Sort(static (l, r) => string.CompareOrdinal(l.ComparisonKey, r.ComparisonKey));
// Main module typically has (devel) as version in source context
@@ -281,6 +333,37 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
metadata["excluded"] = "true";
}
// Add license metadata
if (!string.IsNullOrEmpty(module.License))
{
metadata["license"] = module.License;
if (module.LicenseConfidence != GoLicenseDetector.LicenseConfidence.None)
{
metadata["license.confidence"] = module.LicenseConfidence.ToString().ToLowerInvariant();
}
}
// Add pseudo-version indicator
if (module.IsPseudoVersion)
{
metadata["pseudoVersion"] = "true";
}
// Add conflict metadata for this specific module
var conflict = inventory.ConflictAnalysis.GetConflict(module.Path);
if (conflict is not null)
{
metadata["conflict.detected"] = "true";
metadata["conflict.severity"] = conflict.Severity.ToString().ToLowerInvariant();
metadata["conflict.type"] = conflict.ConflictType.ToString();
var otherVersions = conflict.OtherVersions.Take(5).ToList();
if (otherVersions.Count > 0)
{
metadata["conflict.otherVersions"] = string.Join(",", otherVersions);
}
}
var evidence = new List<LanguageComponentEvidence>();
// Evidence from go.mod
@@ -428,6 +511,28 @@ public sealed class GoLanguageAnalyzer : ILanguageAnalyzer
AddIfMissing(entries, "build.vcs.modified", dwarf.Modified?.ToString()?.ToLowerInvariant());
AddIfMissing(entries, "build.vcs.time", dwarf.TimestampUtc);
}
// Extract explicit CGO metadata from build settings
var cgoSettings = GoCgoDetector.ExtractFromBuildSettings(buildInfo.Settings);
if (cgoSettings.CgoEnabled)
{
AddIfMissing(entries, "cgo.enabled", "true");
AddIfMissing(entries, "cgo.cflags", cgoSettings.CgoFlags);
AddIfMissing(entries, "cgo.ldflags", cgoSettings.CgoLdFlags);
AddIfMissing(entries, "cgo.cc", cgoSettings.CCompiler);
AddIfMissing(entries, "cgo.cxx", cgoSettings.CxxCompiler);
}
// Scan for native libraries alongside the binary
var binaryDir = Path.GetDirectoryName(buildInfo.AbsoluteBinaryPath);
if (!string.IsNullOrEmpty(binaryDir))
{
var nativeLibs = GoCgoDetector.ScanForNativeLibraries(binaryDir);
if (nativeLibs.Count > 0)
{
AddIfMissing(entries, "cgo.nativeLibs", string.Join(",", nativeLibs.Take(10)));
}
}
}
entries.Sort(static (left, right) => string.CompareOrdinal(left.Key, right.Key));

View File

@@ -0,0 +1,398 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Detects CGO usage in Go modules and binaries.
/// Equivalent to Java's JNI detection for native code integration.
/// </summary>
internal static partial class GoCgoDetector
{
/// <summary>
/// Native library file extensions.
/// </summary>
private static readonly string[] NativeLibraryExtensions =
[
".so", // Linux shared library
".dll", // Windows dynamic link library
".dylib", // macOS dynamic library
".a", // Static library (archive)
".lib", // Windows static library
];
/// <summary>
/// Result of CGO analysis for a Go module.
/// </summary>
public sealed record CgoAnalysisResult
{
public static readonly CgoAnalysisResult Empty = new(
false,
ImmutableArray<string>.Empty,
ImmutableArray<CgoDirective>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public CgoAnalysisResult(
bool hasCgoImport,
ImmutableArray<string> cgoFiles,
ImmutableArray<CgoDirective> directives,
ImmutableArray<string> nativeLibraries,
ImmutableArray<string> includedHeaders)
{
HasCgoImport = hasCgoImport;
CgoFiles = cgoFiles;
Directives = directives;
NativeLibraries = nativeLibraries;
IncludedHeaders = includedHeaders;
}
/// <summary>
/// True if any Go file imports "C".
/// </summary>
public bool HasCgoImport { get; }
/// <summary>
/// List of Go files containing CGO imports.
/// </summary>
public ImmutableArray<string> CgoFiles { get; }
/// <summary>
/// Parsed #cgo directives from source files.
/// </summary>
public ImmutableArray<CgoDirective> Directives { get; }
/// <summary>
/// Native libraries found alongside Go source/binary.
/// </summary>
public ImmutableArray<string> NativeLibraries { get; }
/// <summary>
/// C headers included in cgo preamble.
/// </summary>
public ImmutableArray<string> IncludedHeaders { get; }
/// <summary>
/// Returns true if any CGO usage was detected.
/// </summary>
public bool IsEmpty => !HasCgoImport && CgoFiles.IsEmpty && NativeLibraries.IsEmpty;
/// <summary>
/// Gets CFLAGS from directives.
/// </summary>
public string? GetCFlags()
=> GetDirectiveValues("CFLAGS");
/// <summary>
/// Gets LDFLAGS from directives.
/// </summary>
public string? GetLdFlags()
=> GetDirectiveValues("LDFLAGS");
/// <summary>
/// Gets pkg-config packages from directives.
/// </summary>
public string? GetPkgConfig()
=> GetDirectiveValues("pkg-config");
private string? GetDirectiveValues(string directiveType)
{
var values = Directives
.Where(d => d.Type.Equals(directiveType, StringComparison.OrdinalIgnoreCase))
.Select(d => d.Value)
.Where(v => !string.IsNullOrWhiteSpace(v))
.Distinct(StringComparer.Ordinal)
.ToList();
return values.Count > 0 ? string.Join(" ", values) : null;
}
}
/// <summary>
/// Represents a parsed #cgo directive.
/// </summary>
public sealed record CgoDirective(
string Type,
string Value,
string? Constraint,
string SourceFile);
/// <summary>
/// Analyzes a Go module directory for CGO usage.
/// </summary>
public static CgoAnalysisResult AnalyzeModule(string modulePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
if (!Directory.Exists(modulePath))
{
return CgoAnalysisResult.Empty;
}
var cgoFiles = new List<string>();
var directives = new List<CgoDirective>();
var headers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var nativeLibs = new List<string>();
// Scan for .go files with CGO imports
var goFiles = EnumerateGoFiles(modulePath);
foreach (var goFile in goFiles)
{
var result = AnalyzeGoFile(goFile);
if (result.HasCgoImport)
{
cgoFiles.Add(Path.GetRelativePath(modulePath, goFile));
directives.AddRange(result.Directives);
foreach (var header in result.Headers)
{
headers.Add(header);
}
}
}
// Scan for native libraries
nativeLibs.AddRange(ScanForNativeLibraries(modulePath));
return new CgoAnalysisResult(
cgoFiles.Count > 0,
[.. cgoFiles.OrderBy(f => f, StringComparer.Ordinal)],
[.. directives],
[.. nativeLibs.Distinct().OrderBy(l => l, StringComparer.Ordinal)],
[.. headers.OrderBy(h => h, StringComparer.Ordinal)]);
}
/// <summary>
/// Extracts CGO settings from build info settings.
/// </summary>
public static CgoBuildSettings ExtractFromBuildSettings(
IEnumerable<KeyValuePair<string, string?>> settings)
{
ArgumentNullException.ThrowIfNull(settings);
string? cgoEnabled = null;
string? cgoFlags = null;
string? cgoLdFlags = null;
string? ccCompiler = null;
string? cxxCompiler = null;
foreach (var setting in settings)
{
switch (setting.Key)
{
case "CGO_ENABLED":
cgoEnabled = setting.Value;
break;
case "CGO_CFLAGS":
cgoFlags = setting.Value;
break;
case "CGO_LDFLAGS":
cgoLdFlags = setting.Value;
break;
case "CC":
ccCompiler = setting.Value;
break;
case "CXX":
cxxCompiler = setting.Value;
break;
}
}
return new CgoBuildSettings(
cgoEnabled?.Equals("1", StringComparison.Ordinal) == true,
cgoFlags,
cgoLdFlags,
ccCompiler,
cxxCompiler);
}
/// <summary>
/// Scans for native libraries in a directory (alongside a binary).
/// </summary>
public static IReadOnlyList<string> ScanForNativeLibraries(string directoryPath)
{
if (!Directory.Exists(directoryPath))
{
return [];
}
var libraries = new List<string>();
try
{
foreach (var file in Directory.EnumerateFiles(directoryPath, "*", SearchOption.TopDirectoryOnly))
{
var extension = Path.GetExtension(file);
if (NativeLibraryExtensions.Any(ext =>
extension.Equals(ext, StringComparison.OrdinalIgnoreCase)))
{
libraries.Add(Path.GetFileName(file));
}
}
}
catch (IOException)
{
// Skip inaccessible directories
}
catch (UnauthorizedAccessException)
{
// Skip inaccessible directories
}
return libraries;
}
private static IEnumerable<string> EnumerateGoFiles(string rootPath)
{
var options = new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MaxRecursionDepth = 10,
};
foreach (var file in Directory.EnumerateFiles(rootPath, "*.go", options))
{
// Skip test files and vendor directory
if (file.EndsWith("_test.go", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (file.Contains($"{Path.DirectorySeparatorChar}vendor{Path.DirectorySeparatorChar}") ||
file.Contains($"{Path.AltDirectorySeparatorChar}vendor{Path.AltDirectorySeparatorChar}"))
{
continue;
}
yield return file;
}
}
private static GoFileAnalysisResult AnalyzeGoFile(string filePath)
{
try
{
var content = File.ReadAllText(filePath);
return AnalyzeGoFileContent(content, filePath);
}
catch (IOException)
{
return GoFileAnalysisResult.Empty;
}
catch (UnauthorizedAccessException)
{
return GoFileAnalysisResult.Empty;
}
}
internal static GoFileAnalysisResult AnalyzeGoFileContent(string content, string filePath)
{
if (string.IsNullOrWhiteSpace(content))
{
return GoFileAnalysisResult.Empty;
}
// Check for import "C"
var hasCgoImport = CgoImportPattern().IsMatch(content);
if (!hasCgoImport)
{
return GoFileAnalysisResult.Empty;
}
var directives = new List<CgoDirective>();
var headers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Find the cgo preamble (comment block before import "C")
var preambleMatch = CgoPreamblePattern().Match(content);
if (preambleMatch.Success)
{
var preamble = preambleMatch.Groups[1].Value;
// Parse #cgo directives
foreach (Match directiveMatch in CgoDirectivePattern().Matches(preamble))
{
var constraint = directiveMatch.Groups[1].Success
? directiveMatch.Groups[1].Value.Trim()
: null;
var directiveType = directiveMatch.Groups[2].Value.Trim();
var directiveValue = directiveMatch.Groups[3].Value.Trim();
directives.Add(new CgoDirective(
directiveType,
directiveValue,
constraint,
filePath));
}
// Parse #include directives for headers
foreach (Match includeMatch in CIncludePattern().Matches(preamble))
{
var header = includeMatch.Groups[1].Value;
if (!string.IsNullOrWhiteSpace(header))
{
headers.Add(header);
}
}
}
return new GoFileAnalysisResult(true, directives, headers.ToList());
}
internal sealed record GoFileAnalysisResult(
bool HasCgoImport,
List<CgoDirective> Directives,
List<string> Headers)
{
public static readonly GoFileAnalysisResult Empty = new(false, [], []);
}
/// <summary>
/// CGO build settings extracted from binary build info.
/// </summary>
public sealed record CgoBuildSettings(
bool CgoEnabled,
string? CgoFlags,
string? CgoLdFlags,
string? CCompiler,
string? CxxCompiler)
{
public static readonly CgoBuildSettings Empty = new(false, null, null, null, null);
/// <summary>
/// Returns true if CGO is enabled.
/// </summary>
public bool IsEmpty => !CgoEnabled &&
string.IsNullOrEmpty(CgoFlags) &&
string.IsNullOrEmpty(CgoLdFlags);
}
// Regex patterns
/// <summary>
/// Matches: import "C" or import ( ... "C" ... )
/// </summary>
[GeneratedRegex(@"import\s*(?:\(\s*)?""C""", RegexOptions.Multiline)]
private static partial Regex CgoImportPattern();
/// <summary>
/// Matches the cgo preamble comment block before import "C".
/// </summary>
[GeneratedRegex(@"/\*\s*((?:#.*?\n|.*?\n)*?)\s*\*/\s*import\s*""C""", RegexOptions.Singleline)]
private static partial Regex CgoPreamblePattern();
/// <summary>
/// Matches #cgo directives with optional build constraints.
/// Format: #cgo [GOOS GOARCH] DIRECTIVE: value
/// </summary>
[GeneratedRegex(@"#cgo\s+(?:([a-z0-9_,!\s]+)\s+)?(\w+):\s*(.+?)(?=\n|$)", RegexOptions.Multiline | RegexOptions.IgnoreCase)]
private static partial Regex CgoDirectivePattern();
/// <summary>
/// Matches C #include directives.
/// </summary>
[GeneratedRegex(@"#include\s*[<""]([^>""]+)[>""]", RegexOptions.Multiline)]
private static partial Regex CIncludePattern();
}

View File

@@ -0,0 +1,336 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Detects and normalizes licenses for Go modules.
/// Scans LICENSE files and converts to SPDX identifiers.
/// </summary>
internal static partial class GoLicenseDetector
{
/// <summary>
/// Common license file names to scan.
/// </summary>
private static readonly string[] LicenseFileNames =
[
"LICENSE",
"LICENSE.txt",
"LICENSE.md",
"LICENSE.rst",
"LICENCE", // British spelling
"LICENCE.txt",
"LICENCE.md",
"COPYING",
"COPYING.txt",
"COPYING.md",
"MIT-LICENSE",
"MIT-LICENSE.txt",
"APACHE-LICENSE",
"APACHE-LICENSE.txt",
"APACHE-2.0.txt",
"UNLICENSE",
"UNLICENSE.txt",
];
/// <summary>
/// License patterns mapped to SPDX identifiers.
/// Order matters - more specific patterns first.
/// </summary>
private static readonly LicensePattern[] LicensePatterns =
[
// Apache variants
new("Apache-2.0", @"Apache License.*?(?:Version 2\.0|v2\.0)", "Apache License, Version 2.0"),
new("Apache-1.1", @"Apache License.*?(?:Version 1\.1|v1\.1)", "Apache License, Version 1.1"),
new("Apache-1.0", @"Apache License.*?(?:Version 1\.0|v1\.0)", "Apache License, Version 1.0"),
// MIT variants
new("MIT", @"(?:MIT License|Permission is hereby granted, free of charge)", "MIT License"),
new("MIT-0", @"MIT No Attribution", "MIT No Attribution"),
// BSD variants (order matters - check 3-clause before 2-clause)
new("BSD-3-Clause", @"BSD 3-Clause|Redistribution and use.*?3\. Neither the name", "BSD 3-Clause License"),
new("BSD-2-Clause", @"BSD 2-Clause|Redistribution and use.*?provided that the following conditions", "BSD 2-Clause License"),
new("BSD-3-Clause-Clear", @"BSD-3-Clause-Clear|clear BSD", "BSD 3-Clause Clear License"),
new("0BSD", @"Zero-Clause BSD|BSD Zero Clause", "BSD Zero Clause License"),
// GPL variants
new("GPL-3.0-only", @"GNU GENERAL PUBLIC LICENSE.*?Version 3", "GNU General Public License v3.0 only"),
new("GPL-3.0-or-later", @"GNU GENERAL PUBLIC LICENSE.*?Version 3.*?or \(at your option\) any later", "GNU General Public License v3.0 or later"),
new("GPL-2.0-only", @"GNU GENERAL PUBLIC LICENSE.*?Version 2(?!.*or later)", "GNU General Public License v2.0 only"),
new("GPL-2.0-or-later", @"GNU GENERAL PUBLIC LICENSE.*?Version 2.*?or \(at your option\) any later", "GNU General Public License v2.0 or later"),
// LGPL variants
new("LGPL-3.0-only", @"GNU LESSER GENERAL PUBLIC LICENSE.*?Version 3", "GNU Lesser General Public License v3.0 only"),
new("LGPL-2.1-only", @"GNU LESSER GENERAL PUBLIC LICENSE.*?Version 2\.1", "GNU Lesser General Public License v2.1 only"),
new("LGPL-2.0-only", @"GNU LIBRARY GENERAL PUBLIC LICENSE.*?Version 2", "GNU Library General Public License v2 only"),
// AGPL variants
new("AGPL-3.0-only", @"GNU AFFERO GENERAL PUBLIC LICENSE.*?Version 3", "GNU Affero General Public License v3.0 only"),
// Mozilla
new("MPL-2.0", @"Mozilla Public License.*?(?:Version 2\.0|v2\.0|2\.0)", "Mozilla Public License 2.0"),
new("MPL-1.1", @"Mozilla Public License.*?(?:Version 1\.1|v1\.1|1\.1)", "Mozilla Public License 1.1"),
// Creative Commons
new("CC-BY-4.0", @"Creative Commons Attribution 4\.0", "Creative Commons Attribution 4.0"),
new("CC-BY-SA-4.0", @"Creative Commons Attribution-ShareAlike 4\.0", "Creative Commons Attribution ShareAlike 4.0"),
new("CC0-1.0", @"CC0 1\.0|Creative Commons Zero", "Creative Commons Zero v1.0 Universal"),
// Other common licenses
new("ISC", @"ISC License|Permission to use, copy, modify, and/or distribute", "ISC License"),
new("Unlicense", @"This is free and unencumbered software released into the public domain", "The Unlicense"),
new("WTFPL", @"DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE", "Do What The F*ck You Want To Public License"),
new("Zlib", @"zlib License|This software is provided 'as-is'", "zlib License"),
new("BSL-1.0", @"Boost Software License", "Boost Software License 1.0"),
new("PostgreSQL", @"PostgreSQL License", "PostgreSQL License"),
new("BlueOak-1.0.0", @"Blue Oak Model License", "Blue Oak Model License 1.0.0"),
// Dual/multiple license indicators
new("MIT OR Apache-2.0", @"(?:MIT|Apache)[/\s]+(?:OR|AND|/)[/\s]+(?:Apache|MIT)", "MIT OR Apache-2.0"),
];
/// <summary>
/// Result of license detection for a module.
/// </summary>
public sealed record LicenseInfo(
string? SpdxIdentifier,
string? LicenseFile,
string? RawLicenseName,
LicenseConfidence Confidence)
{
public static readonly LicenseInfo Unknown = new(null, null, null, LicenseConfidence.None);
/// <summary>
/// Returns true if a license was detected.
/// </summary>
public bool IsDetected => !string.IsNullOrEmpty(SpdxIdentifier);
}
/// <summary>
/// Confidence level for license detection.
/// </summary>
public enum LicenseConfidence
{
/// <summary>No license detected.</summary>
None = 0,
/// <summary>Matched by heuristic or partial match.</summary>
Low = 1,
/// <summary>Matched by pattern with good confidence.</summary>
Medium = 2,
/// <summary>Exact SPDX identifier found or strong pattern match.</summary>
High = 3
}
/// <summary>
/// Detects license for a Go module at the given path.
/// </summary>
public static LicenseInfo DetectLicense(string modulePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
if (!Directory.Exists(modulePath))
{
return LicenseInfo.Unknown;
}
// Search for license files
foreach (var licenseFileName in LicenseFileNames)
{
var licensePath = Path.Combine(modulePath, licenseFileName);
if (File.Exists(licensePath))
{
var result = AnalyzeLicenseFile(licensePath);
if (result.IsDetected)
{
return result;
}
}
}
// Check for license in a docs subdirectory
var docsPath = Path.Combine(modulePath, "docs");
if (Directory.Exists(docsPath))
{
foreach (var licenseFileName in LicenseFileNames)
{
var licensePath = Path.Combine(docsPath, licenseFileName);
if (File.Exists(licensePath))
{
var result = AnalyzeLicenseFile(licensePath);
if (result.IsDetected)
{
return result;
}
}
}
}
return LicenseInfo.Unknown;
}
/// <summary>
/// Detects license for a vendored module.
/// </summary>
public static LicenseInfo DetectVendoredLicense(string vendorPath, string modulePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vendorPath);
ArgumentException.ThrowIfNullOrWhiteSpace(modulePath);
// vendor/<module-path>/LICENSE
var vendoredModulePath = Path.Combine(vendorPath, modulePath.Replace('/', Path.DirectorySeparatorChar));
if (Directory.Exists(vendoredModulePath))
{
return DetectLicense(vendoredModulePath);
}
return LicenseInfo.Unknown;
}
/// <summary>
/// Analyzes a license file and returns detected license info.
/// </summary>
public static LicenseInfo AnalyzeLicenseFile(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
try
{
// Read first 8KB of file (should be enough for license detection)
var content = ReadFileHead(filePath, 8192);
if (string.IsNullOrWhiteSpace(content))
{
return LicenseInfo.Unknown;
}
return AnalyzeLicenseContent(content, filePath);
}
catch (IOException)
{
return LicenseInfo.Unknown;
}
catch (UnauthorizedAccessException)
{
return LicenseInfo.Unknown;
}
}
/// <summary>
/// Analyzes license content and returns detected license info.
/// </summary>
internal static LicenseInfo AnalyzeLicenseContent(string content, string? sourceFile = null)
{
if (string.IsNullOrWhiteSpace(content))
{
return LicenseInfo.Unknown;
}
// Check for explicit SPDX identifier first (highest confidence)
var spdxMatch = SpdxIdentifierPattern().Match(content);
if (spdxMatch.Success)
{
var spdxId = spdxMatch.Groups[1].Value.Trim();
return new LicenseInfo(spdxId, sourceFile, spdxId, LicenseConfidence.High);
}
// Try pattern matching
foreach (var pattern in LicensePatterns)
{
if (pattern.CompiledRegex.IsMatch(content))
{
return new LicenseInfo(
pattern.SpdxId,
sourceFile,
pattern.DisplayName,
LicenseConfidence.Medium);
}
}
// Check for common keywords as low-confidence fallback
var keywordLicense = DetectByKeywords(content);
if (keywordLicense is not null)
{
return new LicenseInfo(
keywordLicense,
sourceFile,
keywordLicense,
LicenseConfidence.Low);
}
return LicenseInfo.Unknown;
}
private static string? DetectByKeywords(string content)
{
var upperContent = content.ToUpperInvariant();
// Very basic keyword detection as fallback
if (upperContent.Contains("MIT"))
{
return "MIT";
}
if (upperContent.Contains("APACHE"))
{
return "Apache-2.0"; // Default to 2.0
}
if (upperContent.Contains("BSD"))
{
return "BSD-3-Clause"; // Default to 3-clause
}
if (upperContent.Contains("GPL"))
{
return "GPL-3.0-only"; // Default to 3.0
}
if (upperContent.Contains("PUBLIC DOMAIN") || upperContent.Contains("UNLICENSE"))
{
return "Unlicense";
}
return null;
}
private static string ReadFileHead(string filePath, int maxBytes)
{
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var buffer = new byte[Math.Min(maxBytes, stream.Length)];
var bytesRead = stream.Read(buffer, 0, buffer.Length);
// Try UTF-8 first, fall back to ASCII
try
{
return System.Text.Encoding.UTF8.GetString(buffer, 0, bytesRead);
}
catch
{
return System.Text.Encoding.ASCII.GetString(buffer, 0, bytesRead);
}
}
/// <summary>
/// Matches SPDX-License-Identifier comments.
/// </summary>
[GeneratedRegex(@"SPDX-License-Identifier:\s*([A-Za-z0-9\-\.+]+(?:\s+(?:OR|AND)\s+[A-Za-z0-9\-\.+]+)*)", RegexOptions.IgnoreCase)]
private static partial Regex SpdxIdentifierPattern();
/// <summary>
/// Internal record for license patterns.
/// </summary>
private sealed record LicensePattern
{
public LicensePattern(string spdxId, string pattern, string displayName)
{
SpdxId = spdxId;
DisplayName = displayName;
CompiledRegex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
}
public string SpdxId { get; }
public string DisplayName { get; }
public Regex CompiledRegex { get; }
}
}

View File

@@ -27,6 +27,21 @@ internal static class GoSourceInventory
public string Source { get; init; } = "go.mod";
public string ModuleCategory { get; init; } = "public";
public string? Registry { get; init; }
/// <summary>
/// SPDX license identifier if detected.
/// </summary>
public string? License { get; init; }
/// <summary>
/// License detection confidence.
/// </summary>
public GoLicenseDetector.LicenseConfidence LicenseConfidence { get; init; }
/// <summary>
/// True if this is a pseudo-version (unreleased code).
/// </summary>
public bool IsPseudoVersion { get; init; }
}
/// <summary>
@@ -38,18 +53,27 @@ internal static class GoSourceInventory
null,
null,
ImmutableArray<GoSourceModule>.Empty,
ImmutableArray<string>.Empty);
ImmutableArray<string>.Empty,
GoVersionConflictDetector.GoConflictAnalysis.Empty,
GoCgoDetector.CgoAnalysisResult.Empty,
null);
public SourceInventoryResult(
string? modulePath,
string? goVersion,
ImmutableArray<GoSourceModule> modules,
ImmutableArray<string> retractedVersions)
ImmutableArray<string> retractedVersions,
GoVersionConflictDetector.GoConflictAnalysis conflictAnalysis,
GoCgoDetector.CgoAnalysisResult cgoAnalysis,
string? license)
{
ModulePath = modulePath;
GoVersion = goVersion;
Modules = modules;
RetractedVersions = retractedVersions;
ConflictAnalysis = conflictAnalysis;
CgoAnalysis = cgoAnalysis;
License = license;
}
public string? ModulePath { get; }
@@ -57,6 +81,21 @@ internal static class GoSourceInventory
public ImmutableArray<GoSourceModule> Modules { get; }
public ImmutableArray<string> RetractedVersions { get; }
/// <summary>
/// Version conflict analysis for this inventory.
/// </summary>
public GoVersionConflictDetector.GoConflictAnalysis ConflictAnalysis { get; }
/// <summary>
/// CGO usage analysis for this module.
/// </summary>
public GoCgoDetector.CgoAnalysisResult CgoAnalysis { get; }
/// <summary>
/// Main module license (SPDX identifier).
/// </summary>
public string? License { get; }
public bool IsEmpty => Modules.IsEmpty && string.IsNullOrEmpty(ModulePath);
}
@@ -114,6 +153,7 @@ internal static class GoSourceInventory
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(req.Path);
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(req.Path);
var registry = GoPrivateModuleDetector.GetRegistry(req.Path);
var isPseudoVersion = GoVersionConflictDetector.IsPseudoVersion(req.Version);
// Check for replacement
GoModParser.GoModReplace? replacement = null;
@@ -127,6 +167,20 @@ internal static class GoSourceInventory
// Check if excluded
var isExcluded = excludes.Contains(versionedKey);
// Detect license for vendored modules
string? license = null;
var licenseConfidence = GoLicenseDetector.LicenseConfidence.None;
if (isVendored && project.HasVendor)
{
var vendorDir = Path.GetDirectoryName(project.VendorModulesPath);
if (!string.IsNullOrEmpty(vendorDir))
{
var licenseInfo = GoLicenseDetector.DetectVendoredLicense(vendorDir, req.Path);
license = licenseInfo.SpdxIdentifier;
licenseConfidence = licenseInfo.Confidence;
}
}
var module = new GoSourceModule
{
Path = req.Path,
@@ -143,7 +197,10 @@ internal static class GoSourceInventory
ReplacementVersion = replacement?.NewVersion,
Source = isVendored ? "vendor" : "go.mod",
ModuleCategory = moduleCategory,
Registry = registry
Registry = registry,
License = license,
LicenseConfidence = licenseConfidence,
IsPseudoVersion = isPseudoVersion
};
modules.Add(module);
@@ -162,6 +219,21 @@ internal static class GoSourceInventory
{
var isPrivate = GoPrivateModuleDetector.IsLikelyPrivate(vendorMod.Path);
var moduleCategory = GoPrivateModuleDetector.GetModuleCategory(vendorMod.Path);
var isPseudoVersion = GoVersionConflictDetector.IsPseudoVersion(vendorMod.Version);
// Detect license for vendored module
string? license = null;
var licenseConfidence = GoLicenseDetector.LicenseConfidence.None;
if (project.HasVendor)
{
var vendorDir = Path.GetDirectoryName(project.VendorModulesPath);
if (!string.IsNullOrEmpty(vendorDir))
{
var licenseInfo = GoLicenseDetector.DetectVendoredLicense(vendorDir, vendorMod.Path);
license = licenseInfo.SpdxIdentifier;
licenseConfidence = licenseInfo.Confidence;
}
}
modules.Add(new GoSourceModule
{
@@ -176,17 +248,36 @@ internal static class GoSourceInventory
IsRetracted = false,
IsPrivate = isPrivate,
Source = "vendor",
ModuleCategory = moduleCategory
ModuleCategory = moduleCategory,
License = license,
LicenseConfidence = licenseConfidence,
IsPseudoVersion = isPseudoVersion
});
}
}
}
// Perform conflict analysis
var conflictAnalysis = GoVersionConflictDetector.Analyze(
modules,
goMod.Replaces.ToList(),
goMod.Excludes.ToList(),
retractedVersions);
// Analyze CGO usage in the module
var cgoAnalysis = GoCgoDetector.AnalyzeModule(project.RootPath);
// Detect main module license
var mainLicense = GoLicenseDetector.DetectLicense(project.RootPath);
return new SourceInventoryResult(
goMod.ModulePath,
goMod.GoVersion,
modules.ToImmutableArray(),
retractedVersions);
retractedVersions,
conflictAnalysis,
cgoAnalysis,
mainLicense.SpdxIdentifier);
}
/// <summary>

View File

@@ -0,0 +1,442 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.Analyzers.Lang.Go.Internal;
/// <summary>
/// Detects version conflicts in Go module dependencies.
/// Similar to Java's VersionConflictDetector for Maven artifacts.
/// </summary>
internal static partial class GoVersionConflictDetector
{
/// <summary>
/// Conflict severity levels.
/// </summary>
public enum GoConflictSeverity
{
/// <summary>No conflict detected.</summary>
None = 0,
/// <summary>Minor version mismatch or informational.</summary>
Low = 1,
/// <summary>Potential compatibility issue.</summary>
Medium = 2,
/// <summary>Likely breaking change or security concern.</summary>
High = 3
}
/// <summary>
/// Types of version conflicts in Go modules.
/// </summary>
public enum GoConflictType
{
/// <summary>No conflict.</summary>
None,
/// <summary>Module replaced with different version.</summary>
ReplaceOverride,
/// <summary>Module replaced with local path.</summary>
LocalReplacement,
/// <summary>Using pseudo-version (unreleased code).</summary>
PseudoVersion,
/// <summary>Major version mismatch in module path.</summary>
MajorVersionMismatch,
/// <summary>Multiple workspace modules require different versions.</summary>
WorkspaceConflict,
/// <summary>Excluded version is still being required.</summary>
ExcludedVersion,
/// <summary>Using a retracted version.</summary>
RetractedVersion
}
/// <summary>
/// Represents a detected version conflict.
/// </summary>
public sealed record GoVersionConflict(
string ModulePath,
string SelectedVersion,
ImmutableArray<string> RequestedVersions,
GoConflictSeverity Severity,
GoConflictType ConflictType,
string? Description)
{
/// <summary>
/// Gets other versions that were requested but not selected.
/// </summary>
public IEnumerable<string> OtherVersions
=> RequestedVersions.Where(v => !v.Equals(SelectedVersion, StringComparison.Ordinal));
}
/// <summary>
/// Result of conflict analysis for a module inventory.
/// </summary>
public sealed record GoConflictAnalysis
{
public static readonly GoConflictAnalysis Empty = new(
ImmutableArray<GoVersionConflict>.Empty,
ImmutableDictionary<string, GoVersionConflict>.Empty);
public GoConflictAnalysis(
ImmutableArray<GoVersionConflict> conflicts,
ImmutableDictionary<string, GoVersionConflict> byModule)
{
Conflicts = conflicts;
_byModule = byModule;
}
private readonly ImmutableDictionary<string, GoVersionConflict> _byModule;
/// <summary>
/// All detected conflicts.
/// </summary>
public ImmutableArray<GoVersionConflict> Conflicts { get; }
/// <summary>
/// Returns true if any conflicts were detected.
/// </summary>
public bool HasConflicts => Conflicts.Length > 0;
/// <summary>
/// Gets the highest severity among all conflicts.
/// </summary>
public GoConflictSeverity MaxSeverity
=> Conflicts.Length > 0 ? Conflicts.Max(c => c.Severity) : GoConflictSeverity.None;
/// <summary>
/// Gets conflict for a specific module if one exists.
/// </summary>
public GoVersionConflict? GetConflict(string modulePath)
=> _byModule.TryGetValue(modulePath, out var conflict) ? conflict : null;
}
/// <summary>
/// Analyzes module inventory for version conflicts.
/// </summary>
public static GoConflictAnalysis Analyze(
IReadOnlyList<GoSourceInventory.GoSourceModule> modules,
IReadOnlyList<GoModParser.GoModReplace> replaces,
IReadOnlyList<GoModParser.GoModExclude> excludes,
ImmutableArray<string> retractedVersions)
{
ArgumentNullException.ThrowIfNull(modules);
ArgumentNullException.ThrowIfNull(replaces);
ArgumentNullException.ThrowIfNull(excludes);
if (modules.Count == 0)
{
return GoConflictAnalysis.Empty;
}
var conflicts = new List<GoVersionConflict>();
// Build exclude set for quick lookup
var excludeSet = excludes
.Select(e => $"{e.Path}@{e.Version}")
.ToImmutableHashSet(StringComparer.Ordinal);
// Build replace map
var replaceMap = replaces.ToDictionary(
r => r.OldVersion is not null ? $"{r.OldPath}@{r.OldVersion}" : r.OldPath,
r => r,
StringComparer.Ordinal);
foreach (var module in modules)
{
// Check for pseudo-version
if (IsPseudoVersion(module.Version))
{
conflicts.Add(new GoVersionConflict(
module.Path,
module.Version,
[module.Version],
GoConflictSeverity.Medium,
GoConflictType.PseudoVersion,
"Using pseudo-version indicates unreleased or unstable code"));
}
// Check for replace directive conflicts
if (module.IsReplaced)
{
var severity = GoConflictSeverity.Low;
var conflictType = GoConflictType.ReplaceOverride;
var description = $"Module replaced with {module.ReplacementPath}";
// Local path replacement is higher risk
if (IsLocalPath(module.ReplacementPath))
{
severity = GoConflictSeverity.High;
conflictType = GoConflictType.LocalReplacement;
description = "Module replaced with local path - may not be reproducible";
}
conflicts.Add(new GoVersionConflict(
module.Path,
module.Version,
[module.Version],
severity,
conflictType,
description));
}
// Check for excluded version being required
var versionedKey = $"{module.Path}@{module.Version}";
if (excludeSet.Contains(versionedKey))
{
conflicts.Add(new GoVersionConflict(
module.Path,
module.Version,
[module.Version],
GoConflictSeverity.High,
GoConflictType.ExcludedVersion,
"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"));
}
}
// Check for major version mismatches
var modulesByBasePath = modules
.GroupBy(m => ExtractBasePath(m.Path), StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1);
foreach (var group in modulesByBasePath)
{
var versions = group.Select(m => ExtractMajorVersion(m.Path)).Distinct().ToList();
if (versions.Count > 1)
{
foreach (var module in group)
{
var otherVersions = group
.Where(m => !m.Path.Equals(module.Path, StringComparison.Ordinal))
.Select(m => m.Version)
.ToImmutableArray();
conflicts.Add(new GoVersionConflict(
module.Path,
module.Version,
[module.Version, .. otherVersions],
GoConflictSeverity.Medium,
GoConflictType.MajorVersionMismatch,
$"Multiple major versions of same module: {string.Join(", ", versions)}"));
}
}
}
var byModule = conflicts
.GroupBy(c => c.ModulePath, StringComparer.Ordinal)
.Select(g => g.OrderByDescending(c => c.Severity).First())
.ToImmutableDictionary(c => c.ModulePath, c => c, StringComparer.Ordinal);
return new GoConflictAnalysis(
[.. conflicts.OrderBy(c => c.ModulePath, StringComparer.Ordinal)],
byModule);
}
/// <summary>
/// Analyzes workspace for cross-module version conflicts.
/// </summary>
public static GoConflictAnalysis AnalyzeWorkspace(
IReadOnlyList<GoSourceInventory.SourceInventoryResult> inventories)
{
ArgumentNullException.ThrowIfNull(inventories);
if (inventories.Count < 2)
{
return GoConflictAnalysis.Empty;
}
var conflicts = new List<GoVersionConflict>();
// Group all modules by path across workspace members
var allModules = inventories
.SelectMany(inv => inv.Modules)
.GroupBy(m => m.Path, StringComparer.Ordinal);
foreach (var group in allModules)
{
var versions = group
.Select(m => m.Version)
.Distinct(StringComparer.Ordinal)
.ToList();
if (versions.Count > 1)
{
// Different versions of same dependency across workspace
var selectedVersion = SelectMvsVersion(versions);
conflicts.Add(new GoVersionConflict(
group.Key,
selectedVersion,
[.. versions],
GoConflictSeverity.Low,
GoConflictType.WorkspaceConflict,
$"Workspace modules require different versions: {string.Join(", ", versions)}"));
}
}
var byModule = conflicts
.ToImmutableDictionary(c => c.ModulePath, c => c, StringComparer.Ordinal);
return new GoConflictAnalysis([.. conflicts], byModule);
}
/// <summary>
/// Determines if a version string is a pseudo-version.
/// Pseudo-versions have format: v0.0.0-yyyymmddhhmmss-abcdefabcdef
/// </summary>
public static bool IsPseudoVersion(string version)
{
if (string.IsNullOrWhiteSpace(version))
{
return false;
}
return PseudoVersionPattern().IsMatch(version);
}
/// <summary>
/// Determines if a path is a local filesystem path.
/// </summary>
private static bool IsLocalPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
// Starts with ./ or ../ or /
if (path.StartsWith('.') || path.StartsWith('/') || path.StartsWith('\\'))
{
return true;
}
// Windows absolute path
if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':')
{
return true;
}
return false;
}
/// <summary>
/// Extracts the base path without major version suffix.
/// Example: "github.com/user/repo/v2" -> "github.com/user/repo"
/// </summary>
private static string ExtractBasePath(string modulePath)
{
var match = MajorVersionSuffixPattern().Match(modulePath);
return match.Success ? modulePath[..^match.Length] : modulePath;
}
/// <summary>
/// Extracts major version from module path.
/// Example: "github.com/user/repo/v2" -> "v2"
/// </summary>
private static string ExtractMajorVersion(string modulePath)
{
var match = MajorVersionSuffixPattern().Match(modulePath);
return match.Success ? match.Value : "v0/v1";
}
/// <summary>
/// Simulates Go's Minimal Version Selection to pick the highest version.
/// </summary>
private static string SelectMvsVersion(IEnumerable<string> versions)
{
// MVS picks the highest version among all requested
return versions
.OrderByDescending(v => v, SemVerComparer.Instance)
.First();
}
/// <summary>
/// Matches pseudo-versions: v0.0.0-timestamp-hash or vX.Y.Z-pre.0.timestamp-hash
/// </summary>
[GeneratedRegex(@"^v\d+\.\d+\.\d+(-[a-z0-9]+)?\.?\d*\.?\d{14}-[a-f0-9]{12}$", RegexOptions.IgnoreCase)]
private static partial Regex PseudoVersionPattern();
/// <summary>
/// Matches major version suffix: /v2, /v3, etc.
/// </summary>
[GeneratedRegex(@"/v\d+$")]
private static partial Regex MajorVersionSuffixPattern();
/// <summary>
/// Comparer for semantic versions that handles Go module versions.
/// </summary>
private sealed class SemVerComparer : IComparer<string>
{
public static readonly SemVerComparer Instance = new();
public int Compare(string? x, string? y)
{
if (x is null && y is null) return 0;
if (x is null) return -1;
if (y is null) return 1;
var partsX = ParseVersion(x);
var partsY = ParseVersion(y);
// Compare major.minor.patch
for (var i = 0; i < 3; i++)
{
var comparison = partsX[i].CompareTo(partsY[i]);
if (comparison != 0) return comparison;
}
// Compare pre-release (no pre-release > pre-release)
var preX = partsX[3] > 0 || !string.IsNullOrEmpty(GetPrerelease(x));
var preY = partsY[3] > 0 || !string.IsNullOrEmpty(GetPrerelease(y));
if (!preX && preY) return 1;
if (preX && !preY) return -1;
return string.CompareOrdinal(x, y);
}
private static int[] ParseVersion(string version)
{
var result = new int[4]; // major, minor, patch, prerelease indicator
// Strip 'v' prefix
if (version.StartsWith('v') || version.StartsWith('V'))
{
version = version[1..];
}
// Handle pseudo-versions specially
if (version.Contains('-'))
{
var dashIndex = version.IndexOf('-');
version = version[..dashIndex];
result[3] = 1; // Mark as pre-release
}
var parts = version.Split('.');
for (var i = 0; i < Math.Min(parts.Length, 3); i++)
{
if (int.TryParse(parts[i], out var num))
{
result[i] = num;
}
}
return result;
}
private static string? GetPrerelease(string version)
{
var dashIndex = version.IndexOf('-');
return dashIndex >= 0 ? version[(dashIndex + 1)..] : null;
}
}
}

View File

@@ -8,6 +8,10 @@
<EnableDefaultItems>false</EnableDefaultItems>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.Scanner.Analyzers.Lang.Go.Tests" />
</ItemGroup>
<ItemGroup>
<Compile Include="**\\*.cs" Exclude="obj\\**;bin\\**" />
<EmbeddedResource Include="**\\*.json" Exclude="obj\\**;bin\\**" />