feat: add security sink detection patterns for JavaScript/TypeScript

- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations).
- Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns.
- Added `package-lock.json` for dependency management.
This commit is contained in:
StellaOps Bot
2025-12-22 23:21:21 +02:00
parent 3ba7157b00
commit 5146204f1b
529 changed files with 73579 additions and 5985 deletions

View File

@@ -0,0 +1,101 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Emit.Lineage;
/// <summary>
/// Interface for content-addressable SBOM storage with lineage tracking.
/// </summary>
public interface ISbomStore
{
/// <summary>
/// Stores an SBOM with optional parent reference.
/// </summary>
/// <param name="sbomContent">The canonical SBOM content.</param>
/// <param name="imageDigest">The image digest.</param>
/// <param name="parentId">Optional parent SBOM ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The assigned SBOM ID.</returns>
Task<SbomId> StoreAsync(
string sbomContent,
string imageDigest,
SbomId? parentId = null,
CancellationToken ct = default);
/// <summary>
/// Gets an SBOM by its content hash.
/// </summary>
Task<SbomLineage?> GetByHashAsync(string contentHash, CancellationToken ct = default);
/// <summary>
/// Gets an SBOM by its ID.
/// </summary>
Task<SbomLineage?> GetByIdAsync(SbomId id, CancellationToken ct = default);
/// <summary>
/// Gets the lineage chain for an SBOM.
/// </summary>
Task<ImmutableArray<SbomLineage>> GetLineageAsync(SbomId id, CancellationToken ct = default);
/// <summary>
/// Gets the diff between two SBOMs.
/// </summary>
Task<SbomDiff?> GetDiffAsync(SbomId fromId, SbomId toId, CancellationToken ct = default);
/// <summary>
/// Gets all SBOM versions for an image.
/// </summary>
Task<ImmutableArray<SbomLineage>> GetByImageDigestAsync(string imageDigest, CancellationToken ct = default);
}
/// <summary>
/// Extension methods for SBOM lineage traversal.
/// </summary>
public static class SbomLineageExtensions
{
/// <summary>
/// Gets the full ancestor chain as a list.
/// </summary>
public static async Task<IReadOnlyList<SbomLineage>> GetFullAncestryAsync(
this ISbomStore store,
SbomId id,
CancellationToken ct = default)
{
var ancestry = new List<SbomLineage>();
var current = await store.GetByIdAsync(id, ct);
while (current != null)
{
ancestry.Add(current);
if (current.ParentId is null)
break;
current = await store.GetByIdAsync(current.ParentId.Value, ct);
}
return ancestry;
}
/// <summary>
/// Finds the common ancestor of two SBOM versions.
/// </summary>
public static async Task<SbomId?> FindCommonAncestorAsync(
this ISbomStore store,
SbomId id1,
SbomId id2,
CancellationToken ct = default)
{
var lineage1 = await store.GetLineageAsync(id1, ct);
var lineage2 = await store.GetLineageAsync(id2, ct);
var ancestors1 = lineage1.Select(l => l.Id).ToHashSet();
foreach (var ancestor in lineage2)
{
if (ancestors1.Contains(ancestor.Id))
return ancestor.Id;
}
return null;
}
}

View File

@@ -0,0 +1,162 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Emit.Lineage;
/// <summary>
/// Proof manifest that enables reproducible SBOM generation.
/// </summary>
public sealed record RebuildProof
{
/// <summary>
/// The SBOM ID this proof applies to.
/// </summary>
public required SbomId SbomId { get; init; }
/// <summary>
/// The image digest that was scanned.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// Version of Stella Ops used for the scan.
/// </summary>
public required string StellaOpsVersion { get; init; }
/// <summary>
/// Snapshots of all feeds used during the scan.
/// </summary>
public required ImmutableArray<FeedSnapshot> FeedSnapshots { get; init; }
/// <summary>
/// Versions of all analyzers used during the scan.
/// </summary>
public required ImmutableArray<AnalyzerVersion> AnalyzerVersions { get; init; }
/// <summary>
/// Hash of the policy configuration used.
/// </summary>
public required string PolicyHash { get; init; }
/// <summary>
/// When the proof was generated.
/// </summary>
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// DSSE signature of the proof (optional).
/// </summary>
public string? DsseSignature { get; init; }
/// <summary>
/// Hash of the entire proof document.
/// </summary>
public string? ProofHash { get; init; }
}
/// <summary>
/// Snapshot of a vulnerability/advisory feed at a point in time.
/// </summary>
public sealed record FeedSnapshot
{
/// <summary>
/// Unique feed identifier.
/// </summary>
public required string FeedId { get; init; }
/// <summary>
/// Feed name/description.
/// </summary>
public required string FeedName { get; init; }
/// <summary>
/// Hash of the feed content at snapshot time.
/// </summary>
public required string SnapshotHash { get; init; }
/// <summary>
/// When the snapshot was taken.
/// </summary>
public required DateTimeOffset AsOf { get; init; }
/// <summary>
/// Number of entries in the feed.
/// </summary>
public int? EntryCount { get; init; }
/// <summary>
/// Feed version/revision if available.
/// </summary>
public string? FeedVersion { get; init; }
}
/// <summary>
/// Version of an analyzer used during scanning.
/// </summary>
public sealed record AnalyzerVersion
{
/// <summary>
/// Analyzer identifier.
/// </summary>
public required string AnalyzerId { get; init; }
/// <summary>
/// Analyzer name.
/// </summary>
public required string AnalyzerName { get; init; }
/// <summary>
/// Version string.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Hash of analyzer code/rules if available.
/// </summary>
public string? CodeHash { get; init; }
/// <summary>
/// Configuration hash if applicable.
/// </summary>
public string? ConfigHash { get; init; }
}
/// <summary>
/// Result of a rebuild verification.
/// </summary>
public sealed record RebuildVerification
{
/// <summary>
/// The proof that was verified.
/// </summary>
public required RebuildProof Proof { get; init; }
/// <summary>
/// Whether the rebuild was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The SBOM produced by the rebuild.
/// </summary>
public SbomId? RebuiltSbomId { get; init; }
/// <summary>
/// Whether the rebuilt SBOM matches the original.
/// </summary>
public bool? HashMatches { get; init; }
/// <summary>
/// Differences if the rebuild didn't match.
/// </summary>
public SbomDiff? Differences { get; init; }
/// <summary>
/// Error message if rebuild failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// When the verification was performed.
/// </summary>
public required DateTimeOffset VerifiedAt { get; init; }
}

View File

@@ -0,0 +1,168 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Emit.Lineage;
/// <summary>
/// Semantic diff between two SBOM versions.
/// </summary>
public sealed record SbomDiff
{
/// <summary>
/// Source SBOM ID.
/// </summary>
public required SbomId FromId { get; init; }
/// <summary>
/// Target SBOM ID.
/// </summary>
public required SbomId ToId { get; init; }
/// <summary>
/// Individual component-level changes.
/// </summary>
public required ImmutableArray<ComponentDelta> Deltas { get; init; }
/// <summary>
/// Summary of the diff.
/// </summary>
public required DiffSummary Summary { get; init; }
/// <summary>
/// When the diff was computed.
/// </summary>
public required DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// A single component-level change.
/// </summary>
public sealed record ComponentDelta
{
/// <summary>
/// Type of change.
/// </summary>
public required ComponentDeltaType Type { get; init; }
/// <summary>
/// The component reference before the change (null if added).
/// </summary>
public ComponentRef? Before { get; init; }
/// <summary>
/// The component reference after the change (null if removed).
/// </summary>
public ComponentRef? After { get; init; }
/// <summary>
/// List of fields that changed (for modified components).
/// </summary>
public ImmutableArray<string> ChangedFields { get; init; } = [];
}
/// <summary>
/// Type of component change.
/// </summary>
public enum ComponentDeltaType
{
/// <summary>
/// Component was added.
/// </summary>
Added,
/// <summary>
/// Component was removed.
/// </summary>
Removed,
/// <summary>
/// Component version changed.
/// </summary>
VersionChanged,
/// <summary>
/// Component license changed.
/// </summary>
LicenseChanged,
/// <summary>
/// Component dependencies changed.
/// </summary>
DependencyChanged,
/// <summary>
/// Other metadata changed.
/// </summary>
MetadataChanged
}
/// <summary>
/// Reference to a component.
/// </summary>
public sealed record ComponentRef
{
/// <summary>
/// Package URL (PURL).
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Component name.
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Component version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Component type/ecosystem.
/// </summary>
public string? Type { get; init; }
/// <summary>
/// License expression (SPDX).
/// </summary>
public string? License { get; init; }
}
/// <summary>
/// Summary statistics for a diff.
/// </summary>
public sealed record DiffSummary
{
/// <summary>
/// Number of components added.
/// </summary>
public required int Added { get; init; }
/// <summary>
/// Number of components removed.
/// </summary>
public required int Removed { get; init; }
/// <summary>
/// Number of components with version changes.
/// </summary>
public required int VersionChanged { get; init; }
/// <summary>
/// Number of components with other modifications.
/// </summary>
public required int OtherModified { get; init; }
/// <summary>
/// Number of components unchanged.
/// </summary>
public required int Unchanged { get; init; }
/// <summary>
/// Total components in target SBOM.
/// </summary>
public int TotalComponents => Added + VersionChanged + OtherModified + Unchanged;
/// <summary>
/// Is this a breaking change (any removals or version downgrades)?
/// </summary>
public bool IsBreaking { get; init; }
}

View File

@@ -0,0 +1,195 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Emit.Lineage;
/// <summary>
/// Engine for computing semantic diffs between SBOM versions.
/// </summary>
public sealed class SbomDiffEngine
{
/// <summary>
/// Computes the semantic diff between two SBOMs.
/// </summary>
public SbomDiff ComputeDiff(
SbomId fromId,
IReadOnlyList<ComponentRef> fromComponents,
SbomId toId,
IReadOnlyList<ComponentRef> toComponents)
{
var fromByPurl = fromComponents.ToDictionary(c => c.Purl, c => c);
var toByPurl = toComponents.ToDictionary(c => c.Purl, c => c);
var deltas = new List<ComponentDelta>();
var added = 0;
var removed = 0;
var versionChanged = 0;
var otherModified = 0;
var unchanged = 0;
var isBreaking = false;
// Find added and modified components
foreach (var (purl, toComp) in toByPurl)
{
if (!fromByPurl.TryGetValue(purl, out var fromComp))
{
// Added
deltas.Add(new ComponentDelta
{
Type = ComponentDeltaType.Added,
After = toComp
});
added++;
}
else
{
// Possibly modified
var changedFields = CompareComponents(fromComp, toComp);
if (changedFields.Length > 0)
{
var deltaType = changedFields.Contains("Version")
? ComponentDeltaType.VersionChanged
: changedFields.Contains("License")
? ComponentDeltaType.LicenseChanged
: ComponentDeltaType.MetadataChanged;
deltas.Add(new ComponentDelta
{
Type = deltaType,
Before = fromComp,
After = toComp,
ChangedFields = changedFields
});
if (deltaType == ComponentDeltaType.VersionChanged)
versionChanged++;
else
otherModified++;
// Check for version downgrade (breaking)
if (changedFields.Contains("Version") && IsVersionDowngrade(fromComp.Version, toComp.Version))
isBreaking = true;
}
else
{
unchanged++;
}
}
}
// Find removed components
foreach (var (purl, fromComp) in fromByPurl)
{
if (!toByPurl.ContainsKey(purl))
{
deltas.Add(new ComponentDelta
{
Type = ComponentDeltaType.Removed,
Before = fromComp
});
removed++;
isBreaking = true;
}
}
// Sort deltas for determinism
var sortedDeltas = deltas
.OrderBy(d => d.Type)
.ThenBy(d => d.Before?.Purl ?? d.After?.Purl)
.ToImmutableArray();
return new SbomDiff
{
FromId = fromId,
ToId = toId,
Deltas = sortedDeltas,
Summary = new DiffSummary
{
Added = added,
Removed = removed,
VersionChanged = versionChanged,
OtherModified = otherModified,
Unchanged = unchanged,
IsBreaking = isBreaking
},
ComputedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// Creates a diff pointer from a diff.
/// </summary>
public SbomDiffPointer CreatePointer(SbomDiff diff)
{
var hash = ComputeDiffHash(diff);
return new SbomDiffPointer
{
ComponentsAdded = diff.Summary.Added,
ComponentsRemoved = diff.Summary.Removed,
ComponentsModified = diff.Summary.VersionChanged + diff.Summary.OtherModified,
DiffHash = hash
};
}
private static ImmutableArray<string> CompareComponents(ComponentRef from, ComponentRef to)
{
var changes = new List<string>();
if (from.Version != to.Version)
changes.Add("Version");
if (from.License != to.License)
changes.Add("License");
if (from.Type != to.Type)
changes.Add("Type");
return [.. changes];
}
private static bool IsVersionDowngrade(string fromVersion, string toVersion)
{
// Simple semver-like comparison
// In production, use proper version comparison per ecosystem
try
{
var fromParts = fromVersion.Split('.').Select(int.Parse).ToArray();
var toParts = toVersion.Split('.').Select(int.Parse).ToArray();
for (var i = 0; i < Math.Min(fromParts.Length, toParts.Length); i++)
{
if (toParts[i] < fromParts[i]) return true;
if (toParts[i] > fromParts[i]) return false;
}
return toParts.Length < fromParts.Length;
}
catch
{
// Fall back to string comparison
return string.Compare(toVersion, fromVersion, StringComparison.Ordinal) < 0;
}
}
private static string ComputeDiffHash(SbomDiff diff)
{
var json = JsonSerializer.Serialize(new
{
diff.FromId,
diff.ToId,
Deltas = diff.Deltas.Select(d => new
{
d.Type,
BeforePurl = d.Before?.Purl,
AfterPurl = d.After?.Purl,
d.ChangedFields
})
}, new JsonSerializerOptions { WriteIndented = false });
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexStringLower(hashBytes);
}
}

View File

@@ -0,0 +1,85 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Emit.Lineage;
/// <summary>
/// Represents an SBOM with lineage tracking to its parent versions.
/// </summary>
public sealed record SbomLineage
{
/// <summary>
/// Unique identifier for this SBOM version.
/// </summary>
public required SbomId Id { get; init; }
/// <summary>
/// Parent SBOM ID (null if this is the first version).
/// </summary>
public SbomId? ParentId { get; init; }
/// <summary>
/// The image digest this SBOM describes.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// Content-addressable hash (SHA-256 of canonical SBOM).
/// </summary>
public required string ContentHash { get; init; }
/// <summary>
/// When this SBOM version was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Ancestor chain (parent, grandparent, etc.).
/// </summary>
public ImmutableArray<SbomId> Ancestors { get; init; } = [];
/// <summary>
/// Pointer to the diff from parent (null if no parent).
/// </summary>
public SbomDiffPointer? DiffFromParent { get; init; }
}
/// <summary>
/// Strongly-typed SBOM identifier.
/// </summary>
public readonly record struct SbomId(Guid Value)
{
public static SbomId New() => new(Guid.NewGuid());
public static SbomId Parse(string value) => new(Guid.Parse(value));
public override string ToString() => Value.ToString();
}
/// <summary>
/// Pointer to a diff document with summary statistics.
/// </summary>
public sealed record SbomDiffPointer
{
/// <summary>
/// Number of components added since parent.
/// </summary>
public required int ComponentsAdded { get; init; }
/// <summary>
/// Number of components removed since parent.
/// </summary>
public required int ComponentsRemoved { get; init; }
/// <summary>
/// Number of components modified since parent.
/// </summary>
public required int ComponentsModified { get; init; }
/// <summary>
/// Hash of the diff document for retrieval.
/// </summary>
public required string DiffHash { get; init; }
/// <summary>
/// Total number of changes.
/// </summary>
public int TotalChanges => ComponentsAdded + ComponentsRemoved + ComponentsModified;
}

View File

@@ -14,7 +14,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CycloneDX.Core" Version="11.0.0" />
<PackageReference Include="CycloneDX.Core" Version="10.0.2" />
<PackageReference Include="RoaringBitmap" Version="0.0.9" />
</ItemGroup>