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:
StellaOps Bot
2025-12-06 11:20:35 +02:00
parent b978ae399f
commit a7cd10020a
85 changed files with 7414 additions and 42 deletions

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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,
}

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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>());
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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('\\', '/');
}
}

View File

@@ -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));
}
}
}

View File

@@ -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
}
}

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,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"
}
}