- Implemented comprehensive tests for VexLensNormalizer including format detection and normalization scenarios. - Added tests for CpeParser covering CPE 2.3 and 2.2 formats, invalid inputs, and canonical key generation. - Created tests for ProductMapper to validate parsing and matching logic across different strictness levels. - Developed tests for PurlParser to ensure correct parsing of various PURL formats and validation of identifiers. - Introduced stubs for Monaco editor and worker to facilitate testing in the web application. - Updated project file for the test project to include necessary dependencies.
289 lines
8.1 KiB
C#
289 lines
8.1 KiB
C#
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; }
|
|
public bool IsOptional { get; private init; }
|
|
public bool IsPeer { get; private init; }
|
|
|
|
/// <summary>
|
|
/// Source type: npm, git, tarball, file, link, workspace.
|
|
/// </summary>
|
|
public string SourceType { get; private init; } = "npm";
|
|
|
|
/// <summary>
|
|
/// Git commit hash for git dependencies.
|
|
/// </summary>
|
|
public string? GitCommit { get; private init; }
|
|
|
|
/// <summary>
|
|
/// Original specifier (e.g., "github:user/repo#tag").
|
|
/// </summary>
|
|
public string? Specifier { get; private init; }
|
|
|
|
/// <summary>
|
|
/// Direct dependencies of this package (for transitive analysis).
|
|
/// </summary>
|
|
public IReadOnlyList<string> Dependencies { get; private init; } = Array.Empty<string>();
|
|
|
|
/// <summary>
|
|
/// Whether this is a direct dependency (in root package.json) or transitive.
|
|
/// </summary>
|
|
public bool IsDirect { get; set; }
|
|
|
|
/// <summary>
|
|
/// Whether this package has been patched (via patchedDependencies or .patches directory).
|
|
/// </summary>
|
|
public bool IsPatched { get; set; }
|
|
|
|
/// <summary>
|
|
/// Path to the patch file if this package is patched.
|
|
/// </summary>
|
|
public string? PatchFile { get; set; }
|
|
|
|
/// <summary>
|
|
/// Custom registry URL if this package comes from a non-default registry.
|
|
/// </summary>
|
|
public string? CustomRegistry { get; set; }
|
|
|
|
/// <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,
|
|
IsOptional = lockEntry?.IsOptional ?? false,
|
|
IsPeer = lockEntry?.IsPeer ?? false,
|
|
SourceType = lockEntry?.SourceType ?? "npm",
|
|
GitCommit = lockEntry?.GitCommit,
|
|
Specifier = lockEntry?.Specifier,
|
|
Dependencies = lockEntry?.Dependencies ?? Array.Empty<string>()
|
|
};
|
|
}
|
|
|
|
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,
|
|
IsOptional = entry.IsOptional,
|
|
IsPeer = entry.IsPeer,
|
|
SourceType = entry.SourceType,
|
|
GitCommit = entry.GitCommit,
|
|
Specifier = entry.Specifier,
|
|
Dependencies = entry.Dependencies
|
|
};
|
|
}
|
|
|
|
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 (!string.IsNullOrEmpty(CustomRegistry))
|
|
{
|
|
metadata["customRegistry"] = CustomRegistry;
|
|
}
|
|
|
|
if (IsDev)
|
|
{
|
|
metadata["dev"] = "true";
|
|
}
|
|
|
|
if (IsDirect)
|
|
{
|
|
metadata["direct"] = "true";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(GitCommit))
|
|
{
|
|
metadata["gitCommit"] = GitCommit;
|
|
}
|
|
|
|
if (IsOptional)
|
|
{
|
|
metadata["optional"] = "true";
|
|
}
|
|
|
|
metadata["packageManager"] = "bun";
|
|
|
|
if (IsPatched)
|
|
{
|
|
metadata["patched"] = "true";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(PatchFile))
|
|
{
|
|
metadata["patchFile"] = NormalizePath(PatchFile);
|
|
}
|
|
|
|
if (IsPeer)
|
|
{
|
|
metadata["peer"] = "true";
|
|
}
|
|
|
|
if (SourceType != "npm")
|
|
{
|
|
metadata["sourceType"] = SourceType;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(Specifier))
|
|
{
|
|
metadata["specifier"] = Specifier;
|
|
}
|
|
|
|
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('\\', '/');
|
|
}
|
|
}
|