feat: Add Bun language analyzer and related functionality
- Implemented BunPackageNormalizer to deduplicate packages by name and version. - Created BunProjectDiscoverer to identify Bun project roots in the filesystem. - Added project files for the Bun analyzer including manifest and project configuration. - Developed comprehensive tests for Bun language analyzer covering various scenarios. - Included fixture files for testing standard installs, isolated linker installs, lockfile-only scenarios, and workspaces. - Established stubs for authentication sessions to facilitate testing in the web application.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Plugin;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun;
|
||||
|
||||
/// <summary>
|
||||
/// Restart-time plugin that exposes the Bun language analyzer.
|
||||
/// </summary>
|
||||
public sealed class BunAnalyzerPlugin : ILanguageAnalyzerPlugin
|
||||
{
|
||||
public string Name => "StellaOps.Scanner.Analyzers.Lang.Bun";
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => services is not null;
|
||||
|
||||
public ILanguageAnalyzer CreateAnalyzer(IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
return new BunLanguageAnalyzer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun;
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes Bun-based JavaScript projects for npm dependency inventory.
|
||||
/// Supports bun.lock text lockfiles, node_modules traversal, and isolated linker installs.
|
||||
/// </summary>
|
||||
public sealed class BunLanguageAnalyzer : ILanguageAnalyzer
|
||||
{
|
||||
public string Id => "bun";
|
||||
|
||||
public string DisplayName => "Bun Analyzer";
|
||||
|
||||
public async ValueTask AnalyzeAsync(LanguageAnalyzerContext context, LanguageComponentWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
// Stage 1: Discover Bun project roots
|
||||
var projectRoots = BunProjectDiscoverer.Discover(context, cancellationToken);
|
||||
if (projectRoots.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var projectRoot in projectRoots)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Stage 2: Classify input type (installed vs lockfile vs unsupported)
|
||||
var classification = BunInputNormalizer.Classify(context, projectRoot, cancellationToken);
|
||||
|
||||
// Handle unsupported bun.lockb
|
||||
if (classification.Kind == BunInputKind.BinaryLockfileOnly)
|
||||
{
|
||||
EmitBinaryLockfileRemediation(writer, context, projectRoot);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stage 3: Collect packages based on classification
|
||||
IReadOnlyList<BunPackage> packages;
|
||||
if (classification.Kind == BunInputKind.InstalledModules)
|
||||
{
|
||||
// Prefer installed modules when available
|
||||
var lockData = classification.HasTextLockfile
|
||||
? await BunLockParser.ParseAsync(classification.TextLockfilePath!, cancellationToken).ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
packages = BunInstalledCollector.Collect(context, projectRoot, lockData, cancellationToken);
|
||||
}
|
||||
else if (classification.Kind == BunInputKind.TextLockfileOnly)
|
||||
{
|
||||
// Fall back to lockfile parsing
|
||||
var lockData = await BunLockParser.ParseAsync(classification.TextLockfilePath!, cancellationToken).ConfigureAwait(false);
|
||||
packages = BunLockInventory.ExtractPackages(lockData, classification.IncludeDev);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No usable artifacts
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stage 4: Normalize and emit
|
||||
var normalized = BunPackageNormalizer.Normalize(packages);
|
||||
foreach (var package in normalized.OrderBy(static p => p.ComponentKey, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var metadata = package.CreateMetadata();
|
||||
var evidence = package.CreateEvidence();
|
||||
|
||||
writer.AddFromPurl(
|
||||
analyzerId: Id,
|
||||
purl: package.Purl,
|
||||
name: package.Name,
|
||||
version: package.Version,
|
||||
type: "npm",
|
||||
metadata: metadata,
|
||||
evidence: evidence,
|
||||
usedByEntrypoint: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitBinaryLockfileRemediation(LanguageComponentWriter writer, LanguageAnalyzerContext context, string projectRoot)
|
||||
{
|
||||
var relativePath = context.GetRelativePath(projectRoot);
|
||||
|
||||
var evidence = new[]
|
||||
{
|
||||
new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.Metadata,
|
||||
"bun.lockb",
|
||||
relativePath,
|
||||
"Binary lockfile detected; text lockfile required for SCA.",
|
||||
null)
|
||||
};
|
||||
|
||||
var metadata = new Dictionary<string, string?>
|
||||
{
|
||||
["remediation"] = "Run 'bun install --save-text-lockfile' to generate bun.lock, then remove bun.lockb.",
|
||||
["severity"] = "info",
|
||||
["type"] = "unsupported-artifact"
|
||||
};
|
||||
|
||||
writer.AddFromExplicitKey(
|
||||
analyzerId: Id,
|
||||
componentKey: $"remediation::bun-binary-lockfile::{relativePath}",
|
||||
purl: null,
|
||||
name: "Bun Binary Lockfile",
|
||||
version: null,
|
||||
type: "bun-remediation",
|
||||
metadata: metadata,
|
||||
evidence: evidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Classification result for a Bun project root.
|
||||
/// </summary>
|
||||
internal sealed class BunInputClassification
|
||||
{
|
||||
public required BunInputKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to bun.lock if present.
|
||||
/// </summary>
|
||||
public string? TextLockfilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to bun.lockb if present.
|
||||
/// </summary>
|
||||
public string? BinaryLockfilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to node_modules if present.
|
||||
/// </summary>
|
||||
public string? NodeModulesPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to node_modules/.bun if present (isolated linker store).
|
||||
/// </summary>
|
||||
public string? BunStorePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include dev dependencies when extracting from lockfile.
|
||||
/// </summary>
|
||||
public bool IncludeDev { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// True if a text lockfile (bun.lock) is available.
|
||||
/// </summary>
|
||||
public bool HasTextLockfile => !string.IsNullOrEmpty(TextLockfilePath);
|
||||
|
||||
/// <summary>
|
||||
/// True if installed modules are present.
|
||||
/// </summary>
|
||||
public bool HasInstalledModules => !string.IsNullOrEmpty(NodeModulesPath);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the type of Bun project input available for scanning.
|
||||
/// </summary>
|
||||
internal enum BunInputKind
|
||||
{
|
||||
/// <summary>
|
||||
/// No Bun artifacts found or no usable input.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Installed node_modules present (preferred path).
|
||||
/// </summary>
|
||||
InstalledModules,
|
||||
|
||||
/// <summary>
|
||||
/// Only bun.lock text lockfile available (no node_modules).
|
||||
/// </summary>
|
||||
TextLockfileOnly,
|
||||
|
||||
/// <summary>
|
||||
/// Only bun.lockb binary lockfile present (unsupported).
|
||||
/// </summary>
|
||||
BinaryLockfileOnly,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a Bun project root to determine the best scanning strategy.
|
||||
/// </summary>
|
||||
internal static class BunInputNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Classifies the input type for a Bun project root.
|
||||
/// </summary>
|
||||
public static BunInputClassification Classify(LanguageAnalyzerContext context, string projectRoot, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectRoot);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var nodeModulesPath = Path.Combine(projectRoot, "node_modules");
|
||||
var bunStorePath = Path.Combine(projectRoot, "node_modules", ".bun");
|
||||
var textLockfilePath = Path.Combine(projectRoot, "bun.lock");
|
||||
var binaryLockfilePath = Path.Combine(projectRoot, "bun.lockb");
|
||||
|
||||
var hasNodeModules = Directory.Exists(nodeModulesPath);
|
||||
var hasBunStore = Directory.Exists(bunStorePath);
|
||||
var hasTextLockfile = File.Exists(textLockfilePath);
|
||||
var hasBinaryLockfile = File.Exists(binaryLockfilePath);
|
||||
|
||||
// Decision heuristic per the advisory:
|
||||
// 1. If node_modules exists → installed inventory path
|
||||
// 2. Else if bun.lock exists → lockfile inventory path
|
||||
// 3. Else if bun.lockb exists → emit unsupported + remediation
|
||||
// 4. Else → no Bun evidence
|
||||
|
||||
if (hasNodeModules)
|
||||
{
|
||||
return new BunInputClassification
|
||||
{
|
||||
Kind = BunInputKind.InstalledModules,
|
||||
NodeModulesPath = nodeModulesPath,
|
||||
BunStorePath = hasBunStore ? bunStorePath : null,
|
||||
TextLockfilePath = hasTextLockfile ? textLockfilePath : null,
|
||||
BinaryLockfilePath = hasBinaryLockfile ? binaryLockfilePath : null,
|
||||
IncludeDev = true
|
||||
};
|
||||
}
|
||||
|
||||
if (hasTextLockfile)
|
||||
{
|
||||
return new BunInputClassification
|
||||
{
|
||||
Kind = BunInputKind.TextLockfileOnly,
|
||||
TextLockfilePath = textLockfilePath,
|
||||
BinaryLockfilePath = hasBinaryLockfile ? binaryLockfilePath : null,
|
||||
IncludeDev = true // Default to true for lockfile-only scans
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBinaryLockfile)
|
||||
{
|
||||
return new BunInputClassification
|
||||
{
|
||||
Kind = BunInputKind.BinaryLockfileOnly,
|
||||
BinaryLockfilePath = binaryLockfilePath
|
||||
};
|
||||
}
|
||||
|
||||
return new BunInputClassification
|
||||
{
|
||||
Kind = BunInputKind.None
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Collects packages from installed node_modules with symlink-safe traversal.
|
||||
/// Supports both standard hoisted installs and Bun's isolated linker store.
|
||||
/// </summary>
|
||||
internal static class BunInstalledCollector
|
||||
{
|
||||
private const int MaxFilesPerRoot = 50000;
|
||||
private const int MaxSymlinkDepth = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Collects packages from installed node_modules.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<BunPackage> Collect(
|
||||
LanguageAnalyzerContext context,
|
||||
string projectRoot,
|
||||
BunLockData? lockData,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectRoot);
|
||||
|
||||
var packages = new List<BunPackage>();
|
||||
var visitedInodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
var fileCount = 0;
|
||||
|
||||
var nodeModulesPath = Path.Combine(projectRoot, "node_modules");
|
||||
if (Directory.Exists(nodeModulesPath))
|
||||
{
|
||||
CollectFromDirectory(
|
||||
nodeModulesPath,
|
||||
projectRoot,
|
||||
lockData,
|
||||
packages,
|
||||
visitedInodes,
|
||||
ref fileCount,
|
||||
0,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Also scan node_modules/.bun for isolated linker packages
|
||||
var bunStorePath = Path.Combine(projectRoot, "node_modules", ".bun");
|
||||
if (Directory.Exists(bunStorePath))
|
||||
{
|
||||
CollectFromDirectory(
|
||||
bunStorePath,
|
||||
projectRoot,
|
||||
lockData,
|
||||
packages,
|
||||
visitedInodes,
|
||||
ref fileCount,
|
||||
0,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return packages.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static void CollectFromDirectory(
|
||||
string directory,
|
||||
string projectRoot,
|
||||
BunLockData? lockData,
|
||||
List<BunPackage> packages,
|
||||
HashSet<string> visitedInodes,
|
||||
ref int fileCount,
|
||||
int symlinkDepth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (fileCount >= MaxFilesPerRoot || symlinkDepth > MaxSymlinkDepth)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get real path and check if already visited
|
||||
var realPath = TryGetRealPath(directory);
|
||||
if (realPath is not null && !visitedInodes.Add(realPath))
|
||||
{
|
||||
return; // Already visited this real path
|
||||
}
|
||||
|
||||
// Check if this directory is a package (has package.json)
|
||||
var packageJsonPath = Path.Combine(directory, "package.json");
|
||||
if (File.Exists(packageJsonPath))
|
||||
{
|
||||
fileCount++;
|
||||
var package = TryParsePackage(packageJsonPath, directory, realPath, projectRoot, lockData);
|
||||
if (package is not null)
|
||||
{
|
||||
packages.Add(package);
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse subdirectories
|
||||
try
|
||||
{
|
||||
foreach (var subdir in Directory.EnumerateDirectories(directory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (fileCount >= MaxFilesPerRoot)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var dirName = Path.GetFileName(subdir);
|
||||
|
||||
// Skip hidden directories (except .bin, .bun)
|
||||
if (dirName.StartsWith('.') && dirName is not ".bin" and not ".bun")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate symlink depth
|
||||
var nextSymlinkDepth = IsSymlink(subdir) ? symlinkDepth + 1 : symlinkDepth;
|
||||
|
||||
// Verify symlink stays within project root
|
||||
if (IsSymlink(subdir))
|
||||
{
|
||||
var targetPath = TryGetRealPath(subdir);
|
||||
if (targetPath is null || !IsWithinRoot(targetPath, projectRoot))
|
||||
{
|
||||
continue; // Skip symlinks pointing outside project
|
||||
}
|
||||
}
|
||||
|
||||
// Handle scoped packages (@scope/name)
|
||||
if (dirName.StartsWith('@'))
|
||||
{
|
||||
// This is a scope directory, enumerate its packages
|
||||
foreach (var scopedPackageDir in Directory.EnumerateDirectories(subdir))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
CollectFromDirectory(
|
||||
scopedPackageDir,
|
||||
projectRoot,
|
||||
lockData,
|
||||
packages,
|
||||
visitedInodes,
|
||||
ref fileCount,
|
||||
nextSymlinkDepth,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
CollectFromDirectory(
|
||||
subdir,
|
||||
projectRoot,
|
||||
lockData,
|
||||
packages,
|
||||
visitedInodes,
|
||||
ref fileCount,
|
||||
nextSymlinkDepth,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip inaccessible directories
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Directory removed during traversal
|
||||
}
|
||||
}
|
||||
|
||||
private static BunPackage? TryParsePackage(
|
||||
string packageJsonPath,
|
||||
string logicalPath,
|
||||
string? realPath,
|
||||
string projectRoot,
|
||||
BunLockData? lockData)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText(packageJsonPath);
|
||||
using var document = JsonDocument.Parse(content);
|
||||
var root = document.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("name", out var nameElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var name = nameElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var version = root.TryGetProperty("version", out var versionElement)
|
||||
? versionElement.GetString() ?? "0.0.0"
|
||||
: "0.0.0";
|
||||
|
||||
var isPrivate = root.TryGetProperty("private", out var privateElement)
|
||||
&& privateElement.ValueKind == JsonValueKind.True;
|
||||
|
||||
// Look up in lockfile for additional metadata
|
||||
var lockEntry = lockData?.FindEntry(name, version);
|
||||
|
||||
// Get relative path for cleaner output
|
||||
var relativePath = Path.GetRelativePath(projectRoot, logicalPath);
|
||||
var relativeRealPath = realPath is not null ? Path.GetRelativePath(projectRoot, realPath) : null;
|
||||
|
||||
return BunPackage.FromPackageJson(
|
||||
name,
|
||||
version,
|
||||
relativePath,
|
||||
relativeRealPath,
|
||||
isPrivate,
|
||||
lockEntry);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetRealPath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
// ResolveLinkTarget returns the target of the symbolic link
|
||||
var linkTarget = new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true);
|
||||
return linkTarget?.FullName ?? Path.GetFullPath(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSymlink(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attributes = File.GetAttributes(path);
|
||||
return (attributes & FileAttributes.ReparsePoint) != 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWithinRoot(string path, string root)
|
||||
{
|
||||
var normalizedPath = Path.GetFullPath(path).Replace('\\', '/');
|
||||
var normalizedRoot = Path.GetFullPath(root).Replace('\\', '/');
|
||||
|
||||
return normalizedPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed bun.lock data providing fast lookup by package name.
|
||||
/// </summary>
|
||||
internal sealed class BunLockData
|
||||
{
|
||||
private readonly ImmutableDictionary<string, ImmutableArray<BunLockEntry>> _entriesByName;
|
||||
|
||||
public BunLockData(IEnumerable<BunLockEntry> entries)
|
||||
{
|
||||
var grouped = entries
|
||||
.GroupBy(e => e.Name, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary(
|
||||
g => g.Key,
|
||||
g => g.ToImmutableArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
_entriesByName = grouped;
|
||||
AllEntries = entries.ToImmutableArray();
|
||||
}
|
||||
|
||||
public ImmutableArray<BunLockEntry> AllEntries { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Finds a lock entry by name and version.
|
||||
/// </summary>
|
||||
public BunLockEntry? FindEntry(string name, string version)
|
||||
{
|
||||
if (!_entriesByName.TryGetValue(name, out var entries))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entries.FirstOrDefault(e => e.Version == version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all entries for a given package name.
|
||||
/// </summary>
|
||||
public IReadOnlyList<BunLockEntry> GetEntries(string name)
|
||||
{
|
||||
return _entriesByName.TryGetValue(name, out var entries)
|
||||
? entries
|
||||
: ImmutableArray<BunLockEntry>.Empty;
|
||||
}
|
||||
|
||||
public static BunLockData Empty { get; } = new(Array.Empty<BunLockEntry>());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single package entry from bun.lock.
|
||||
/// </summary>
|
||||
internal sealed class BunLockEntry
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public string? Resolved { get; init; }
|
||||
public string? Integrity { get; init; }
|
||||
public bool IsDev { get; init; }
|
||||
public bool IsOptional { get; init; }
|
||||
public bool IsPeer { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts package inventory from parsed bun.lock data.
|
||||
/// </summary>
|
||||
internal static class BunLockInventory
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts packages from lockfile data when no node_modules is present.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<BunPackage> ExtractPackages(BunLockData lockData, bool includeDev = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lockData);
|
||||
|
||||
var packages = new List<BunPackage>();
|
||||
|
||||
foreach (var entry in lockData.AllEntries)
|
||||
{
|
||||
// Filter dev dependencies if requested
|
||||
if (!includeDev && entry.IsDev)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var package = BunPackage.FromLockEntry(entry, "bun.lock");
|
||||
packages.Add(package);
|
||||
}
|
||||
|
||||
return packages.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Parses bun.lock text lockfile format.
|
||||
/// Uses System.Text.Json with JSONC support (comments, trailing commas).
|
||||
/// </summary>
|
||||
internal static class BunLockParser
|
||||
{
|
||||
private const int MaxFileSizeBytes = 50 * 1024 * 1024; // 50 MB limit
|
||||
|
||||
/// <summary>
|
||||
/// Parses a bun.lock file and returns structured lock data.
|
||||
/// </summary>
|
||||
public static async ValueTask<BunLockData> ParseAsync(string lockfilePath, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(lockfilePath);
|
||||
|
||||
if (!File.Exists(lockfilePath))
|
||||
{
|
||||
return BunLockData.Empty;
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(lockfilePath);
|
||||
if (fileInfo.Length > MaxFileSizeBytes)
|
||||
{
|
||||
// File too large, skip parsing
|
||||
return BunLockData.Empty;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(lockfilePath, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses bun.lock content string.
|
||||
/// </summary>
|
||||
internal static BunLockData Parse(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return BunLockData.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use JsonCommentHandling.Skip to handle JSONC-style comments
|
||||
// without manual regex preprocessing that could corrupt URLs
|
||||
using var document = JsonDocument.Parse(content, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
|
||||
var entries = new List<BunLockEntry>();
|
||||
var root = document.RootElement;
|
||||
|
||||
// bun.lock structure: { "lockfileVersion": N, "packages": { ... } }
|
||||
if (root.TryGetProperty("packages", out var packages))
|
||||
{
|
||||
ParsePackages(packages, entries);
|
||||
}
|
||||
|
||||
return new BunLockData(entries);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Malformed lockfile
|
||||
return BunLockData.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParsePackages(JsonElement packages, List<BunLockEntry> entries)
|
||||
{
|
||||
if (packages.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var property in packages.EnumerateObject())
|
||||
{
|
||||
var key = property.Name;
|
||||
var value = property.Value;
|
||||
|
||||
// Skip the root project entry (empty string key or starts with ".")
|
||||
if (string.IsNullOrEmpty(key) || key.StartsWith('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse package key format: name@version or @scope/name@version
|
||||
var (name, version) = ParsePackageKey(key);
|
||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entry = ParsePackageEntry(name, version, value);
|
||||
if (entry is not null)
|
||||
{
|
||||
entries.Add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static (string Name, string Version) ParsePackageKey(string key)
|
||||
{
|
||||
// Format: name@version or @scope/name@version
|
||||
// Need to find the last @ that is not at position 0 (for scoped packages)
|
||||
var atIndex = key.LastIndexOf('@');
|
||||
|
||||
// Handle scoped packages where @ is at the start
|
||||
if (atIndex <= 0)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
// For @scope/name@version, find the @ after the scope
|
||||
if (key.StartsWith('@'))
|
||||
{
|
||||
// Find the @ after the slash
|
||||
var slashIndex = key.IndexOf('/');
|
||||
if (slashIndex > 0 && atIndex > slashIndex)
|
||||
{
|
||||
return (key[..atIndex], key[(atIndex + 1)..]);
|
||||
}
|
||||
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
return (key[..atIndex], key[(atIndex + 1)..]);
|
||||
}
|
||||
|
||||
private static BunLockEntry? ParsePackageEntry(string name, string version, JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Array && element.GetArrayLength() >= 1)
|
||||
{
|
||||
// bun.lock v1 format: [resolved, hash, deps, isDev?]
|
||||
var resolved = element[0].GetString();
|
||||
var integrity = element.GetArrayLength() > 1 ? element[1].GetString() : null;
|
||||
|
||||
return new BunLockEntry
|
||||
{
|
||||
Name = name,
|
||||
Version = version,
|
||||
Resolved = resolved,
|
||||
Integrity = integrity,
|
||||
IsDev = false // Will be determined by dependency graph analysis if needed
|
||||
};
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Object format (future-proofing)
|
||||
var resolved = element.TryGetProperty("resolved", out var r) ? r.GetString() : null;
|
||||
var integrity = element.TryGetProperty("integrity", out var i) ? i.GetString() : null;
|
||||
var isDev = element.TryGetProperty("dev", out var d) && d.GetBoolean();
|
||||
|
||||
return new BunLockEntry
|
||||
{
|
||||
Name = name,
|
||||
Version = version,
|
||||
Resolved = resolved,
|
||||
Integrity = integrity,
|
||||
IsDev = isDev
|
||||
};
|
||||
}
|
||||
|
||||
// Simple string value (just the resolved URL)
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return new BunLockEntry
|
||||
{
|
||||
Name = name,
|
||||
Version = version,
|
||||
Resolved = element.GetString(),
|
||||
Integrity = null,
|
||||
IsDev = false
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Web;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a discovered Bun/npm package with evidence.
|
||||
/// </summary>
|
||||
internal sealed class BunPackage
|
||||
{
|
||||
private readonly List<string> _occurrencePaths = [];
|
||||
|
||||
private BunPackage(string name, string version)
|
||||
{
|
||||
Name = name;
|
||||
Version = version;
|
||||
Purl = BuildPurl(name, version);
|
||||
ComponentKey = $"purl::{Purl}";
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string Version { get; }
|
||||
public string Purl { get; }
|
||||
public string ComponentKey { get; }
|
||||
public string? Resolved { get; private init; }
|
||||
public string? Integrity { get; private init; }
|
||||
public string? Source { get; private init; }
|
||||
public bool IsPrivate { get; private init; }
|
||||
public bool IsDev { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Logical path where this package was found (may be symlink).
|
||||
/// </summary>
|
||||
public string? LogicalPath { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Real path after resolving symlinks.
|
||||
/// </summary>
|
||||
public string? RealPath { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// All filesystem paths where this package (name@version) was found.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> OccurrencePaths => _occurrencePaths.ToImmutableArray();
|
||||
|
||||
public void AddOccurrence(string path)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path) && !_occurrencePaths.Contains(path, StringComparer.Ordinal))
|
||||
{
|
||||
_occurrencePaths.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
public static BunPackage FromPackageJson(
|
||||
string name,
|
||||
string version,
|
||||
string logicalPath,
|
||||
string? realPath,
|
||||
bool isPrivate,
|
||||
BunLockEntry? lockEntry)
|
||||
{
|
||||
return new BunPackage(name, version)
|
||||
{
|
||||
LogicalPath = logicalPath,
|
||||
RealPath = realPath,
|
||||
IsPrivate = isPrivate,
|
||||
Source = "node_modules",
|
||||
Resolved = lockEntry?.Resolved,
|
||||
Integrity = lockEntry?.Integrity,
|
||||
IsDev = lockEntry?.IsDev ?? false
|
||||
};
|
||||
}
|
||||
|
||||
public static BunPackage FromLockEntry(BunLockEntry entry, string source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
return new BunPackage(entry.Name, entry.Version)
|
||||
{
|
||||
Source = source,
|
||||
Resolved = entry.Resolved,
|
||||
Integrity = entry.Integrity,
|
||||
IsDev = entry.IsDev
|
||||
};
|
||||
}
|
||||
|
||||
public IEnumerable<KeyValuePair<string, string?>> CreateMetadata()
|
||||
{
|
||||
var metadata = new SortedDictionary<string, string?>(StringComparer.Ordinal);
|
||||
|
||||
if (!string.IsNullOrEmpty(LogicalPath))
|
||||
{
|
||||
metadata["path"] = NormalizePath(LogicalPath);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(RealPath) && RealPath != LogicalPath)
|
||||
{
|
||||
metadata["realPath"] = NormalizePath(RealPath);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Source))
|
||||
{
|
||||
metadata["source"] = Source;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Resolved))
|
||||
{
|
||||
metadata["resolved"] = Resolved;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Integrity))
|
||||
{
|
||||
metadata["integrity"] = Integrity;
|
||||
}
|
||||
|
||||
if (IsPrivate)
|
||||
{
|
||||
metadata["private"] = "true";
|
||||
}
|
||||
|
||||
if (IsDev)
|
||||
{
|
||||
metadata["dev"] = "true";
|
||||
}
|
||||
|
||||
metadata["packageManager"] = "bun";
|
||||
|
||||
if (_occurrencePaths.Count > 1)
|
||||
{
|
||||
metadata["occurrences"] = string.Join(";", _occurrencePaths.Select(NormalizePath).Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
public IEnumerable<LanguageComponentEvidence> CreateEvidence()
|
||||
{
|
||||
var evidence = new List<LanguageComponentEvidence>();
|
||||
|
||||
if (!string.IsNullOrEmpty(LogicalPath))
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.File,
|
||||
Source ?? "node_modules",
|
||||
NormalizePath(Path.Combine(LogicalPath, "package.json")),
|
||||
null,
|
||||
null));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Resolved))
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.Metadata,
|
||||
"resolved",
|
||||
"bun.lock",
|
||||
Resolved,
|
||||
null));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Integrity))
|
||||
{
|
||||
evidence.Add(new LanguageComponentEvidence(
|
||||
LanguageEvidenceKind.Metadata,
|
||||
"integrity",
|
||||
"bun.lock",
|
||||
Integrity,
|
||||
null));
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static string BuildPurl(string name, string version)
|
||||
{
|
||||
// pkg:npm/<name>@<version>
|
||||
// Scoped packages: @scope/name → %40scope/name
|
||||
var encodedName = name.StartsWith('@')
|
||||
? $"%40{HttpUtility.UrlEncode(name[1..]).Replace("%2f", "/", StringComparison.OrdinalIgnoreCase)}"
|
||||
: HttpUtility.UrlEncode(name);
|
||||
|
||||
return $"pkg:npm/{encodedName}@{version}";
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
// Normalize to forward slashes for cross-platform consistency
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes and deduplicates packages by (name, version).
|
||||
/// Accumulates occurrence paths for traceability.
|
||||
/// </summary>
|
||||
internal static class BunPackageNormalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Deduplicates packages by (name, version), merging occurrence paths.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<BunPackage> Normalize(IReadOnlyList<BunPackage> packages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packages);
|
||||
|
||||
// Group by (name, version)
|
||||
var grouped = packages
|
||||
.GroupBy(p => (p.Name, p.Version), StringTupleComparer.Instance)
|
||||
.Select(MergeGroup)
|
||||
.ToImmutableArray();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private static BunPackage MergeGroup(IGrouping<(string Name, string Version), BunPackage> group)
|
||||
{
|
||||
var first = group.First();
|
||||
|
||||
// Add all occurrences from all packages in the group
|
||||
foreach (var package in group)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(package.LogicalPath))
|
||||
{
|
||||
first.AddOccurrence(package.LogicalPath);
|
||||
}
|
||||
|
||||
foreach (var occurrence in package.OccurrencePaths)
|
||||
{
|
||||
first.AddOccurrence(occurrence);
|
||||
}
|
||||
}
|
||||
|
||||
return first;
|
||||
}
|
||||
|
||||
private sealed class StringTupleComparer : IEqualityComparer<(string, string)>
|
||||
{
|
||||
public static readonly StringTupleComparer Instance = new();
|
||||
|
||||
public bool Equals((string, string) x, (string, string) y)
|
||||
{
|
||||
return StringComparer.Ordinal.Equals(x.Item1, y.Item1)
|
||||
&& StringComparer.Ordinal.Equals(x.Item2, y.Item2);
|
||||
}
|
||||
|
||||
public int GetHashCode((string, string) obj)
|
||||
{
|
||||
return HashCode.Combine(
|
||||
StringComparer.Ordinal.GetHashCode(obj.Item1),
|
||||
StringComparer.Ordinal.GetHashCode(obj.Item2));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Analyzers.Lang.Bun.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers Bun project roots in a filesystem.
|
||||
/// A directory is considered a Bun project root if it contains package.json
|
||||
/// and at least one Bun-specific marker file.
|
||||
/// </summary>
|
||||
internal static class BunProjectDiscoverer
|
||||
{
|
||||
private const int MaxDepth = 10;
|
||||
private const int MaxRoots = 100;
|
||||
|
||||
private static readonly string[] BunMarkers =
|
||||
[
|
||||
"bun.lock",
|
||||
"bun.lockb",
|
||||
"bunfig.toml"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Discovers all Bun project roots under the context root path.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> Discover(LanguageAnalyzerContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var roots = new List<string>();
|
||||
DiscoverRecursive(context.RootPath, 0, roots, cancellationToken);
|
||||
return roots.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static void DiscoverRecursive(string directory, int depth, List<string> roots, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (depth > MaxDepth || roots.Count >= MaxRoots)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this directory is a Bun project root
|
||||
if (IsBunProjectRoot(directory))
|
||||
{
|
||||
roots.Add(directory);
|
||||
// Don't recurse into node_modules or .bun
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
try
|
||||
{
|
||||
foreach (var subdir in Directory.EnumerateDirectories(directory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var dirName = Path.GetFileName(subdir);
|
||||
|
||||
// Skip common non-project directories
|
||||
if (ShouldSkipDirectory(dirName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DiscoverRecursive(subdir, depth + 1, roots, cancellationToken);
|
||||
|
||||
if (roots.Count >= MaxRoots)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Skip directories we can't access
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Directory was removed during traversal
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBunProjectRoot(string directory)
|
||||
{
|
||||
// Must have package.json
|
||||
var packageJsonPath = Path.Combine(directory, "package.json");
|
||||
if (!File.Exists(packageJsonPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for Bun marker files
|
||||
foreach (var marker in BunMarkers)
|
||||
{
|
||||
var markerPath = Path.Combine(directory, marker);
|
||||
if (File.Exists(markerPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for node_modules/.bun (isolated linker store)
|
||||
var bunStorePath = Path.Combine(directory, "node_modules", ".bun");
|
||||
if (Directory.Exists(bunStorePath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ShouldSkipDirectory(string dirName)
|
||||
{
|
||||
return dirName is "node_modules" or ".git" or ".svn" or ".hg" or "bin" or "obj" or ".bun"
|
||||
|| dirName.StartsWith('.'); // Skip hidden directories
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"id": "stellaops.analyzer.lang.bun",
|
||||
"displayName": "StellaOps Bun Analyzer",
|
||||
"version": "0.1.0",
|
||||
"requiresRestart": true,
|
||||
"entryPoint": {
|
||||
"type": "dotnet",
|
||||
"assembly": "StellaOps.Scanner.Analyzers.Lang.Bun.dll",
|
||||
"typeName": "StellaOps.Scanner.Analyzers.Lang.Bun.BunAnalyzerPlugin"
|
||||
},
|
||||
"capabilities": [
|
||||
"language-analyzer",
|
||||
"bun",
|
||||
"npm"
|
||||
],
|
||||
"metadata": {
|
||||
"org.stellaops.analyzer.language": "bun",
|
||||
"org.stellaops.analyzer.kind": "language",
|
||||
"org.stellaops.restart.required": "true"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user