up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,428 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Lifters;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability lifter for Node.js/npm projects.
|
||||
/// Extracts callgraph edges from import/require statements and builds symbol IDs.
|
||||
/// </summary>
|
||||
public sealed class NodeReachabilityLifter : IReachabilityLifter
|
||||
{
|
||||
public string Language => SymbolId.Lang.Node;
|
||||
|
||||
public async ValueTask LiftAsync(ReachabilityLifterContext context, ReachabilityGraphBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
var rootPath = context.RootPath;
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all package.json files
|
||||
var packageJsonFiles = Directory.EnumerateFiles(rootPath, "package.json", SearchOption.AllDirectories)
|
||||
.Where(p => !p.Contains("node_modules", StringComparison.OrdinalIgnoreCase) || IsDirectDependency(rootPath, p))
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
foreach (var packageJsonPath in packageJsonFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ProcessPackageAsync(context, builder, packageJsonPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Process JS/TS files for import edges
|
||||
await ProcessSourceFilesAsync(context, builder, rootPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsDirectDependency(string rootPath, string packageJsonPath)
|
||||
{
|
||||
// Check if it's a direct dependency in node_modules (not nested)
|
||||
var relativePath = Path.GetRelativePath(rootPath, packageJsonPath);
|
||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
// Direct dep: node_modules/<pkg>/package.json or node_modules/@scope/pkg/package.json
|
||||
if (parts.Length < 2 || !parts[0].Equals("node_modules", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Count how many node_modules segments there are
|
||||
var nodeModulesCount = parts.Count(p => p.Equals("node_modules", StringComparison.OrdinalIgnoreCase));
|
||||
return nodeModulesCount == 1;
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessPackageAsync(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
string packageJsonPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(packageJsonPath, cancellationToken).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var pkgName = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(pkgName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pkgVersion = root.TryGetProperty("version", out var verEl) ? verEl.GetString() : "0.0.0";
|
||||
|
||||
// Add package as a module node
|
||||
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
var relativePath = Path.GetRelativePath(context.RootPath, packageJsonPath);
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: moduleSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "module",
|
||||
display: pkgName,
|
||||
sourceFile: NormalizePath(relativePath),
|
||||
sourceLine: null,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = pkgVersion ?? "0.0.0",
|
||||
["purl"] = $"pkg:npm/{EncodePackageName(pkgName)}@{pkgVersion}"
|
||||
});
|
||||
|
||||
// Process entrypoints (main, module, exports)
|
||||
ProcessEntrypoints(context, builder, root, pkgName, relativePath);
|
||||
|
||||
// Process dependencies as edges
|
||||
ProcessDependencies(builder, root, pkgName, "dependencies", EdgeConfidence.Certain);
|
||||
ProcessDependencies(builder, root, pkgName, "devDependencies", EdgeConfidence.Medium);
|
||||
ProcessDependencies(builder, root, pkgName, "peerDependencies", EdgeConfidence.High);
|
||||
ProcessDependencies(builder, root, pkgName, "optionalDependencies", EdgeConfidence.Low);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Invalid JSON, skip
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File access issue, skip
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessEntrypoints(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
JsonElement root,
|
||||
string pkgName,
|
||||
string packageJsonPath)
|
||||
{
|
||||
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
|
||||
// Process "main" field
|
||||
if (root.TryGetProperty("main", out var mainEl) && mainEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var mainPath = mainEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(mainPath))
|
||||
{
|
||||
var entrySymbol = SymbolId.ForNode(pkgName, NormalizePath(mainPath), "entrypoint");
|
||||
builder.AddNode(
|
||||
symbolId: entrySymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "entrypoint",
|
||||
display: $"{pkgName}:{mainPath}",
|
||||
sourceFile: NormalizePath(mainPath));
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
to: entrySymbol,
|
||||
edgeType: EdgeTypes.Loads,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"file:{packageJsonPath}:main");
|
||||
}
|
||||
}
|
||||
|
||||
// Process "module" field (ESM entry)
|
||||
if (root.TryGetProperty("module", out var moduleEl) && moduleEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var modulePath = moduleEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(modulePath))
|
||||
{
|
||||
var entrySymbol = SymbolId.ForNode(pkgName, NormalizePath(modulePath), "entrypoint");
|
||||
builder.AddNode(
|
||||
symbolId: entrySymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "entrypoint",
|
||||
display: $"{pkgName}:{modulePath} (ESM)",
|
||||
sourceFile: NormalizePath(modulePath));
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
to: entrySymbol,
|
||||
edgeType: EdgeTypes.Loads,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"file:{packageJsonPath}:module");
|
||||
}
|
||||
}
|
||||
|
||||
// Process "bin" field
|
||||
if (root.TryGetProperty("bin", out var binEl))
|
||||
{
|
||||
if (binEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var binPath = binEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(binPath))
|
||||
{
|
||||
AddBinEntrypoint(builder, pkgName, pkgName, binPath, packageJsonPath);
|
||||
}
|
||||
}
|
||||
else if (binEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var bin in binEl.EnumerateObject())
|
||||
{
|
||||
if (bin.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var binPath = bin.Value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(binPath))
|
||||
{
|
||||
AddBinEntrypoint(builder, pkgName, bin.Name, binPath, packageJsonPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddBinEntrypoint(
|
||||
ReachabilityGraphBuilder builder,
|
||||
string pkgName,
|
||||
string binName,
|
||||
string binPath,
|
||||
string packageJsonPath)
|
||||
{
|
||||
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
var binSymbol = SymbolId.ForNode(pkgName, NormalizePath(binPath), "bin");
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: binSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "binary",
|
||||
display: $"{binName} -> {binPath}",
|
||||
sourceFile: NormalizePath(binPath),
|
||||
attributes: new Dictionary<string, string> { ["bin_name"] = binName });
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
to: binSymbol,
|
||||
edgeType: EdgeTypes.Spawn,
|
||||
confidence: EdgeConfidence.Certain,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"file:{packageJsonPath}:bin.{binName}");
|
||||
}
|
||||
|
||||
private static void ProcessDependencies(
|
||||
ReachabilityGraphBuilder builder,
|
||||
JsonElement root,
|
||||
string pkgName,
|
||||
string depField,
|
||||
EdgeConfidence confidence)
|
||||
{
|
||||
if (!root.TryGetProperty(depField, out var depsEl) || depsEl.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var moduleSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
|
||||
foreach (var dep in depsEl.EnumerateObject())
|
||||
{
|
||||
var depName = dep.Name;
|
||||
var depVersion = dep.Value.ValueKind == JsonValueKind.String ? dep.Value.GetString() : "*";
|
||||
|
||||
var depSymbol = SymbolId.ForNode(depName, string.Empty, "module");
|
||||
|
||||
// Add the dependency as a node (may already exist)
|
||||
builder.AddNode(
|
||||
symbolId: depSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "module",
|
||||
display: depName);
|
||||
|
||||
// Add edge from this package to the dependency
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
to: depSymbol,
|
||||
edgeType: EdgeTypes.Import,
|
||||
confidence: confidence,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"package.json:{depField}.{depName}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessSourceFilesAsync(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
string rootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var jsFiles = Directory.EnumerateFiles(rootPath, "*.js", SearchOption.AllDirectories)
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.mjs", SearchOption.AllDirectories))
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.cjs", SearchOption.AllDirectories))
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.ts", SearchOption.AllDirectories))
|
||||
.Concat(Directory.EnumerateFiles(rootPath, "*.tsx", SearchOption.AllDirectories))
|
||||
.Where(p => !p.Contains("node_modules", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.Take(500); // Limit to prevent huge graphs
|
||||
|
||||
foreach (var filePath in jsFiles)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await ProcessSourceFileAsync(context, builder, filePath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask ProcessSourceFileAsync(
|
||||
ReachabilityLifterContext context,
|
||||
ReachabilityGraphBuilder builder,
|
||||
string filePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var relativePath = NormalizePath(Path.GetRelativePath(context.RootPath, filePath));
|
||||
|
||||
// Simple regex-based import extraction (Esprima is in the analyzer, not available here)
|
||||
ExtractImports(builder, relativePath, content);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File access issue, skip
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtractImports(ReachabilityGraphBuilder builder, string sourceFile, string content)
|
||||
{
|
||||
var fileSymbol = SymbolId.ForNode(sourceFile, string.Empty, "file");
|
||||
builder.AddNode(
|
||||
symbolId: fileSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "file",
|
||||
display: sourceFile,
|
||||
sourceFile: sourceFile);
|
||||
|
||||
// Extract ES6 imports: import ... from '...'
|
||||
var importMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"import\s+(?:(?:\*\s+as\s+\w+)|(?:\{[^}]*\})|(?:\w+(?:\s*,\s*\{[^}]*\})?)|(?:type\s+\{[^}]*\}))\s+from\s+['""]([^'""]+)['""]",
|
||||
System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in importMatches)
|
||||
{
|
||||
var target = match.Groups[1].Value;
|
||||
AddImportEdge(builder, fileSymbol, sourceFile, target, "import", EdgeConfidence.Certain);
|
||||
}
|
||||
|
||||
// Extract require() calls: require('...')
|
||||
var requireMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"require\s*\(\s*['""]([^'""]+)['""]\s*\)",
|
||||
System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in requireMatches)
|
||||
{
|
||||
var target = match.Groups[1].Value;
|
||||
AddImportEdge(builder, fileSymbol, sourceFile, target, "require", EdgeConfidence.Certain);
|
||||
}
|
||||
|
||||
// Extract dynamic imports: import('...')
|
||||
var dynamicImportMatches = System.Text.RegularExpressions.Regex.Matches(
|
||||
content,
|
||||
@"import\s*\(\s*['""]([^'""]+)['""]\s*\)",
|
||||
System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match match in dynamicImportMatches)
|
||||
{
|
||||
var target = match.Groups[1].Value;
|
||||
AddImportEdge(builder, fileSymbol, sourceFile, target, "import()", EdgeConfidence.High);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddImportEdge(
|
||||
ReachabilityGraphBuilder builder,
|
||||
string fromSymbol,
|
||||
string sourceFile,
|
||||
string target,
|
||||
string kind,
|
||||
EdgeConfidence confidence)
|
||||
{
|
||||
// Determine target symbol
|
||||
string targetSymbol;
|
||||
if (target.StartsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
// Relative import - resolve to file symbol
|
||||
targetSymbol = SymbolId.ForNode(target, string.Empty, "file");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Package import - resolve to module symbol
|
||||
var pkgName = GetPackageNameFromSpecifier(target);
|
||||
targetSymbol = SymbolId.ForNode(pkgName, string.Empty, "module");
|
||||
}
|
||||
|
||||
builder.AddNode(
|
||||
symbolId: targetSymbol,
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: target.StartsWith(".", StringComparison.Ordinal) ? "file" : "module",
|
||||
display: target);
|
||||
|
||||
builder.AddEdge(
|
||||
from: fromSymbol,
|
||||
to: targetSymbol,
|
||||
edgeType: EdgeTypes.Import,
|
||||
confidence: confidence,
|
||||
origin: "static",
|
||||
provenance: Provenance.TsAst,
|
||||
evidence: $"file:{sourceFile}:{kind}");
|
||||
}
|
||||
|
||||
private static string GetPackageNameFromSpecifier(string specifier)
|
||||
{
|
||||
// Handle scoped packages (@scope/pkg)
|
||||
if (specifier.StartsWith("@", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = specifier.Split('/', 3);
|
||||
return parts.Length >= 2 ? $"{parts[0]}/{parts[1]}" : specifier;
|
||||
}
|
||||
|
||||
// Regular package (pkg/subpath)
|
||||
var slashIndex = specifier.IndexOf('/');
|
||||
return slashIndex > 0 ? specifier[..slashIndex] : specifier;
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
return path.Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string EncodePackageName(string name)
|
||||
{
|
||||
if (name.StartsWith("@", StringComparison.Ordinal))
|
||||
{
|
||||
return "%40" + name[1..];
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user