Add unit tests for AST parsing and security sink detection

- Created `StellaOps.AuditPack.Tests.csproj` for unit testing the AuditPack library.
- Implemented comprehensive unit tests in `index.test.js` for AST parsing, covering various JavaScript and TypeScript constructs including functions, classes, decorators, and JSX.
- Added `sink-detect.test.js` to test security sink detection patterns, validating command injection, SQL injection, file write, deserialization, SSRF, NoSQL injection, and more.
- Included tests for taint source detection in various contexts such as Express, Koa, and AWS Lambda.
This commit is contained in:
StellaOps Bot
2025-12-23 09:23:42 +02:00
parent 7e384ab610
commit 56e2dc01ee
96 changed files with 8555 additions and 1455 deletions

View File

@@ -0,0 +1,319 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.Emit.Lineage;
namespace StellaOps.Scanner.Emit.Lineage.Tests;
public class RebuildProofTests
{
#region RebuildProof Model Tests
[Fact]
public void RebuildProof_RequiredProperties_MustBeSet()
{
var proof = new RebuildProof
{
SbomId = SbomId.New(),
ImageDigest = "sha256:abc123",
StellaOpsVersion = "1.0.0",
FeedSnapshots = [],
AnalyzerVersions = [],
PolicyHash = "sha256:policy",
GeneratedAt = DateTimeOffset.UtcNow
};
proof.SbomId.Should().NotBe(default(SbomId));
proof.ImageDigest.Should().NotBeNullOrEmpty();
proof.StellaOpsVersion.Should().Be("1.0.0");
proof.PolicyHash.Should().NotBeNullOrEmpty();
}
[Fact]
public void RebuildProof_WithFeedSnapshots_TracksAllFeeds()
{
var feeds = ImmutableArray.Create(
new FeedSnapshot
{
FeedId = "nvd",
FeedName = "NVD CVE Feed",
SnapshotHash = "sha256:nvdhash",
AsOf = DateTimeOffset.UtcNow,
EntryCount = 200000
},
new FeedSnapshot
{
FeedId = "ghsa",
FeedName = "GitHub Security Advisories",
SnapshotHash = "sha256:ghsahash",
AsOf = DateTimeOffset.UtcNow,
EntryCount = 15000
}
);
var proof = new RebuildProof
{
SbomId = SbomId.New(),
ImageDigest = "sha256:image",
StellaOpsVersion = "1.0.0",
FeedSnapshots = feeds,
AnalyzerVersions = [],
PolicyHash = "sha256:policy",
GeneratedAt = DateTimeOffset.UtcNow
};
proof.FeedSnapshots.Should().HaveCount(2);
proof.FeedSnapshots[0].FeedId.Should().Be("nvd");
proof.FeedSnapshots[1].EntryCount.Should().Be(15000);
}
[Fact]
public void RebuildProof_WithAnalyzerVersions_TracksAllAnalyzers()
{
var analyzers = ImmutableArray.Create(
new AnalyzerVersion
{
AnalyzerId = "npm-analyzer",
AnalyzerName = "NPM Package Analyzer",
Version = "2.0.0",
CodeHash = "sha256:npmhash"
},
new AnalyzerVersion
{
AnalyzerId = "dotnet-analyzer",
AnalyzerName = ".NET Package Analyzer",
Version = "3.1.0"
}
);
var proof = new RebuildProof
{
SbomId = SbomId.New(),
ImageDigest = "sha256:image",
StellaOpsVersion = "1.0.0",
FeedSnapshots = [],
AnalyzerVersions = analyzers,
PolicyHash = "sha256:policy",
GeneratedAt = DateTimeOffset.UtcNow
};
proof.AnalyzerVersions.Should().HaveCount(2);
proof.AnalyzerVersions[0].AnalyzerId.Should().Be("npm-analyzer");
}
[Fact]
public void RebuildProof_OptionalDsseSignature_IsNullByDefault()
{
var proof = new RebuildProof
{
SbomId = SbomId.New(),
ImageDigest = "sha256:image",
StellaOpsVersion = "1.0.0",
FeedSnapshots = [],
AnalyzerVersions = [],
PolicyHash = "sha256:policy",
GeneratedAt = DateTimeOffset.UtcNow
};
proof.DsseSignature.Should().BeNull();
proof.ProofHash.Should().BeNull();
}
[Fact]
public void RebuildProof_WithSignature_StoresSignature()
{
var proof = new RebuildProof
{
SbomId = SbomId.New(),
ImageDigest = "sha256:image",
StellaOpsVersion = "1.0.0",
FeedSnapshots = [],
AnalyzerVersions = [],
PolicyHash = "sha256:policy",
GeneratedAt = DateTimeOffset.UtcNow,
DsseSignature = "eyJwYXlsb2FkIjoiLi4uIn0=",
ProofHash = "sha256:proofhash"
};
proof.DsseSignature.Should().NotBeNullOrEmpty();
proof.ProofHash.Should().StartWith("sha256:");
}
#endregion
#region FeedSnapshot Tests
[Fact]
public void FeedSnapshot_RequiredProperties_MustBeSet()
{
var snapshot = new FeedSnapshot
{
FeedId = "nvd",
FeedName = "NVD CVE Feed",
SnapshotHash = "sha256:hash",
AsOf = DateTimeOffset.UtcNow
};
snapshot.FeedId.Should().Be("nvd");
snapshot.FeedName.Should().Be("NVD CVE Feed");
snapshot.SnapshotHash.Should().NotBeNullOrEmpty();
}
[Fact]
public void FeedSnapshot_OptionalProperties_AreNullByDefault()
{
var snapshot = new FeedSnapshot
{
FeedId = "nvd",
FeedName = "NVD",
SnapshotHash = "sha256:hash",
AsOf = DateTimeOffset.UtcNow
};
snapshot.EntryCount.Should().BeNull();
snapshot.FeedVersion.Should().BeNull();
}
#endregion
#region AnalyzerVersion Tests
[Fact]
public void AnalyzerVersion_RequiredProperties_MustBeSet()
{
var analyzer = new AnalyzerVersion
{
AnalyzerId = "npm-analyzer",
AnalyzerName = "NPM Package Analyzer",
Version = "2.0.0"
};
analyzer.AnalyzerId.Should().Be("npm-analyzer");
analyzer.AnalyzerName.Should().Be("NPM Package Analyzer");
analyzer.Version.Should().Be("2.0.0");
}
[Fact]
public void AnalyzerVersion_OptionalHashes_AreNullByDefault()
{
var analyzer = new AnalyzerVersion
{
AnalyzerId = "test",
AnalyzerName = "Test",
Version = "1.0.0"
};
analyzer.CodeHash.Should().BeNull();
analyzer.ConfigHash.Should().BeNull();
}
#endregion
#region RebuildVerification Tests
[Fact]
public void RebuildVerification_SuccessfulRebuild_HasMatchingHash()
{
var proof = new RebuildProof
{
SbomId = SbomId.New(),
ImageDigest = "sha256:image",
StellaOpsVersion = "1.0.0",
FeedSnapshots = [],
AnalyzerVersions = [],
PolicyHash = "sha256:policy",
GeneratedAt = DateTimeOffset.UtcNow
};
var verification = new RebuildVerification
{
Proof = proof,
Success = true,
RebuiltSbomId = SbomId.New(),
HashMatches = true,
VerifiedAt = DateTimeOffset.UtcNow
};
verification.Success.Should().BeTrue();
verification.HashMatches.Should().BeTrue();
verification.Differences.Should().BeNull();
verification.ErrorMessage.Should().BeNull();
}
[Fact]
public void RebuildVerification_FailedRebuild_HasErrorMessage()
{
var proof = new RebuildProof
{
SbomId = SbomId.New(),
ImageDigest = "sha256:image",
StellaOpsVersion = "1.0.0",
FeedSnapshots = [],
AnalyzerVersions = [],
PolicyHash = "sha256:policy",
GeneratedAt = DateTimeOffset.UtcNow
};
var verification = new RebuildVerification
{
Proof = proof,
Success = false,
ErrorMessage = "Feed snapshot not available",
VerifiedAt = DateTimeOffset.UtcNow
};
verification.Success.Should().BeFalse();
verification.ErrorMessage.Should().Be("Feed snapshot not available");
verification.RebuiltSbomId.Should().BeNull();
}
[Fact]
public void RebuildVerification_MismatchRebuild_HasDifferences()
{
var proof = new RebuildProof
{
SbomId = SbomId.New(),
ImageDigest = "sha256:image",
StellaOpsVersion = "1.0.0",
FeedSnapshots = [],
AnalyzerVersions = [],
PolicyHash = "sha256:policy",
GeneratedAt = DateTimeOffset.UtcNow
};
var diff = new SbomDiff
{
FromId = proof.SbomId,
ToId = SbomId.New(),
Deltas = [],
Summary = new DiffSummary
{
Added = 1,
Removed = 0,
VersionChanged = 0,
OtherModified = 0,
Unchanged = 100
},
ComputedAt = DateTimeOffset.UtcNow
};
var verification = new RebuildVerification
{
Proof = proof,
Success = true,
RebuiltSbomId = SbomId.New(),
HashMatches = false,
Differences = diff,
VerifiedAt = DateTimeOffset.UtcNow
};
verification.Success.Should().BeTrue();
verification.HashMatches.Should().BeFalse();
verification.Differences.Should().NotBeNull();
verification.Differences!.Summary.Added.Should().Be(1);
}
#endregion
}

View File

@@ -0,0 +1,337 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.Emit.Lineage;
namespace StellaOps.Scanner.Emit.Lineage.Tests;
public class SbomDiffEngineTests
{
private readonly SbomDiffEngine _engine = new();
private static ComponentRef CreateComponent(string name, string version, string? license = null)
{
return new ComponentRef
{
Purl = $"pkg:npm/{name}@{version}",
Name = name,
Version = version,
Type = "npm",
License = license
};
}
#region Basic Diff Tests
[Fact]
public void ComputeDiff_IdenticalComponents_ReturnsNoDelta()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var components = new[]
{
CreateComponent("lodash", "4.17.21"),
CreateComponent("express", "4.18.2")
};
var diff = _engine.ComputeDiff(fromId, components, toId, components);
diff.Deltas.Should().BeEmpty();
diff.Summary.Added.Should().Be(0);
diff.Summary.Removed.Should().Be(0);
diff.Summary.VersionChanged.Should().Be(0);
diff.Summary.Unchanged.Should().Be(2);
}
[Fact]
public void ComputeDiff_AddedComponent_DetectsAddition()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[] { CreateComponent("lodash", "4.17.21") };
var to = new[]
{
CreateComponent("lodash", "4.17.21"),
CreateComponent("express", "4.18.2")
};
var diff = _engine.ComputeDiff(fromId, from, toId, to);
diff.Deltas.Should().HaveCount(1);
diff.Deltas[0].Type.Should().Be(ComponentDeltaType.Added);
diff.Deltas[0].After!.Name.Should().Be("express");
diff.Summary.Added.Should().Be(1);
}
[Fact]
public void ComputeDiff_RemovedComponent_DetectsRemoval()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[]
{
CreateComponent("lodash", "4.17.21"),
CreateComponent("express", "4.18.2")
};
var to = new[] { CreateComponent("lodash", "4.17.21") };
var diff = _engine.ComputeDiff(fromId, from, toId, to);
diff.Deltas.Should().HaveCount(1);
diff.Deltas[0].Type.Should().Be(ComponentDeltaType.Removed);
diff.Deltas[0].Before!.Name.Should().Be("express");
diff.Summary.Removed.Should().Be(1);
diff.Summary.IsBreaking.Should().BeTrue();
}
[Fact]
public void ComputeDiff_VersionUpgrade_DetectsVersionChange()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[] { CreateComponent("lodash", "4.17.20") };
var to = new[] { CreateComponent("lodash", "4.17.21") };
var diff = _engine.ComputeDiff(fromId, from, toId, to);
diff.Deltas.Should().HaveCount(1);
diff.Deltas[0].Type.Should().Be(ComponentDeltaType.VersionChanged);
diff.Deltas[0].ChangedFields.Should().Contain("Version");
diff.Summary.VersionChanged.Should().Be(1);
diff.Summary.IsBreaking.Should().BeFalse();
}
[Fact]
public void ComputeDiff_VersionDowngrade_MarksAsBreaking()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[] { CreateComponent("lodash", "4.17.21") };
var to = new[] { CreateComponent("lodash", "4.17.20") };
var diff = _engine.ComputeDiff(fromId, from, toId, to);
diff.Summary.IsBreaking.Should().BeTrue();
}
[Fact]
public void ComputeDiff_LicenseChange_DetectsLicenseChange()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[] { CreateComponent("lodash", "4.17.21", "MIT") };
var to = new[] { CreateComponent("lodash", "4.17.21", "Apache-2.0") };
var diff = _engine.ComputeDiff(fromId, from, toId, to);
diff.Deltas.Should().HaveCount(1);
diff.Deltas[0].Type.Should().Be(ComponentDeltaType.LicenseChanged);
diff.Deltas[0].ChangedFields.Should().Contain("License");
}
#endregion
#region Complex Diff Tests
[Fact]
public void ComputeDiff_MultipleChanges_TracksAll()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[]
{
CreateComponent("lodash", "4.17.20"),
CreateComponent("express", "4.18.1"),
CreateComponent("removed-pkg", "1.0.0")
};
var to = new[]
{
CreateComponent("lodash", "4.17.21"), // Version upgrade
CreateComponent("express", "4.18.1"), // Unchanged
CreateComponent("new-pkg", "2.0.0") // Added
};
var diff = _engine.ComputeDiff(fromId, from, toId, to);
diff.Summary.Added.Should().Be(1);
diff.Summary.Removed.Should().Be(1);
diff.Summary.VersionChanged.Should().Be(1);
diff.Summary.Unchanged.Should().Be(1);
diff.Summary.IsBreaking.Should().BeTrue(); // Due to removal
}
[Fact]
public void ComputeDiff_EmptyFrom_AllAdditions()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = Array.Empty<ComponentRef>();
var to = new[]
{
CreateComponent("lodash", "4.17.21"),
CreateComponent("express", "4.18.2")
};
var diff = _engine.ComputeDiff(fromId, from, toId, to);
diff.Summary.Added.Should().Be(2);
diff.Summary.Removed.Should().Be(0);
diff.Summary.Unchanged.Should().Be(0);
}
[Fact]
public void ComputeDiff_EmptyTo_AllRemovals()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[]
{
CreateComponent("lodash", "4.17.21"),
CreateComponent("express", "4.18.2")
};
var to = Array.Empty<ComponentRef>();
var diff = _engine.ComputeDiff(fromId, from, toId, to);
diff.Summary.Added.Should().Be(0);
diff.Summary.Removed.Should().Be(2);
diff.Summary.IsBreaking.Should().BeTrue();
}
#endregion
#region Determinism Tests
[Fact]
public void ComputeDiff_SameInputs_ProducesSameOutput()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[]
{
CreateComponent("lodash", "4.17.20"),
CreateComponent("express", "4.18.1")
};
var to = new[]
{
CreateComponent("lodash", "4.17.21"),
CreateComponent("new-pkg", "1.0.0")
};
var diff1 = _engine.ComputeDiff(fromId, from, toId, to);
var diff2 = _engine.ComputeDiff(fromId, from, toId, to);
diff1.Summary.Should().BeEquivalentTo(diff2.Summary);
diff1.Deltas.Should().HaveCount(diff2.Deltas.Length);
}
[Fact]
public void ComputeDiff_DeltasAreSorted()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[]
{
CreateComponent("z-pkg", "1.0.0"),
CreateComponent("a-pkg", "1.0.0")
};
var to = new[]
{
CreateComponent("z-pkg", "2.0.0"),
CreateComponent("m-pkg", "1.0.0")
};
var diff = _engine.ComputeDiff(fromId, from, toId, to);
// Deltas should be sorted by type then by PURL
diff.Deltas.Should().BeInAscendingOrder(d => d.Type);
}
#endregion
#region CreatePointer Tests
[Fact]
public void CreatePointer_SumsCorrectly()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[]
{
CreateComponent("lodash", "4.17.20"),
CreateComponent("removed", "1.0.0")
};
var to = new[]
{
CreateComponent("lodash", "4.17.21"),
CreateComponent("added", "1.0.0")
};
var diff = _engine.ComputeDiff(fromId, from, toId, to);
var pointer = _engine.CreatePointer(diff);
pointer.ComponentsAdded.Should().Be(1);
pointer.ComponentsRemoved.Should().Be(1);
pointer.ComponentsModified.Should().Be(1);
pointer.DiffHash.Should().NotBeNullOrEmpty();
}
[Fact]
public void CreatePointer_DiffHashIsDeterministic()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var from = new[] { CreateComponent("lodash", "4.17.20") };
var to = new[] { CreateComponent("lodash", "4.17.21") };
var diff1 = _engine.ComputeDiff(fromId, from, toId, to);
var diff2 = _engine.ComputeDiff(fromId, from, toId, to);
var pointer1 = _engine.CreatePointer(diff1);
var pointer2 = _engine.CreatePointer(diff2);
pointer1.DiffHash.Should().Be(pointer2.DiffHash);
}
#endregion
#region Summary Tests
[Fact]
public void DiffSummary_TotalComponents_CalculatesCorrectly()
{
var summary = new DiffSummary
{
Added = 5,
Removed = 2,
VersionChanged = 3,
OtherModified = 1,
Unchanged = 10,
IsBreaking = false
};
// TotalComponents = Added + VersionChanged + OtherModified + Unchanged
summary.TotalComponents.Should().Be(19);
}
#endregion
}

View File

@@ -0,0 +1,155 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.Emit.Lineage;
namespace StellaOps.Scanner.Emit.Lineage.Tests;
public class SbomLineageTests
{
#region SbomId Tests
[Fact]
public void SbomId_New_CreatesUniqueId()
{
var id1 = SbomId.New();
var id2 = SbomId.New();
id1.Should().NotBe(id2);
}
[Fact]
public void SbomId_Parse_RoundTrips()
{
var original = SbomId.New();
var parsed = SbomId.Parse(original.ToString());
parsed.Should().Be(original);
}
[Fact]
public void SbomId_ToString_ReturnsGuidString()
{
var id = SbomId.New();
var str = id.ToString();
Guid.TryParse(str, out _).Should().BeTrue();
}
#endregion
#region SbomLineage Model Tests
[Fact]
public void SbomLineage_RequiredProperties_MustBeSet()
{
var lineage = new SbomLineage
{
Id = SbomId.New(),
ImageDigest = "sha256:abc123",
ContentHash = "sha256:def456",
CreatedAt = DateTimeOffset.UtcNow
};
lineage.Id.Should().NotBe(default(SbomId));
lineage.ImageDigest.Should().Be("sha256:abc123");
lineage.ContentHash.Should().Be("sha256:def456");
}
[Fact]
public void SbomLineage_WithParent_TracksLineage()
{
var parentId = SbomId.New();
var childId = SbomId.New();
var child = new SbomLineage
{
Id = childId,
ParentId = parentId,
ImageDigest = "sha256:child",
ContentHash = "sha256:childhash",
CreatedAt = DateTimeOffset.UtcNow,
Ancestors = [parentId]
};
child.ParentId.Should().Be(parentId);
child.Ancestors.Should().Contain(parentId);
}
[Fact]
public void SbomLineage_WithDiffPointer_TracksChanges()
{
var diff = new SbomDiffPointer
{
ComponentsAdded = 5,
ComponentsRemoved = 2,
ComponentsModified = 3,
DiffHash = "sha256:diffhash"
};
var lineage = new SbomLineage
{
Id = SbomId.New(),
ParentId = SbomId.New(),
ImageDigest = "sha256:image",
ContentHash = "sha256:content",
CreatedAt = DateTimeOffset.UtcNow,
DiffFromParent = diff
};
lineage.DiffFromParent.Should().NotBeNull();
lineage.DiffFromParent!.TotalChanges.Should().Be(10);
}
[Fact]
public void SbomLineage_RootLineage_HasNoParent()
{
var root = new SbomLineage
{
Id = SbomId.New(),
ImageDigest = "sha256:root",
ContentHash = "sha256:roothash",
CreatedAt = DateTimeOffset.UtcNow
};
root.ParentId.Should().BeNull();
root.Ancestors.Should().BeEmpty();
root.DiffFromParent.Should().BeNull();
}
#endregion
#region SbomDiffPointer Tests
[Fact]
public void SbomDiffPointer_TotalChanges_SumsAllCategories()
{
var pointer = new SbomDiffPointer
{
ComponentsAdded = 10,
ComponentsRemoved = 5,
ComponentsModified = 8,
DiffHash = "sha256:hash"
};
pointer.TotalChanges.Should().Be(23);
}
[Fact]
public void SbomDiffPointer_EmptyDiff_HasZeroChanges()
{
var pointer = new SbomDiffPointer
{
ComponentsAdded = 0,
ComponentsRemoved = 0,
ComponentsModified = 0,
DiffHash = "sha256:empty"
};
pointer.TotalChanges.Should().Be(0);
}
#endregion
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Emit\StellaOps.Scanner.Emit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CycloneDX;
using CycloneDX.Models;
using StellaOps.Scanner.Emit.Spdx.Conversion;
using Xunit;
@@ -53,9 +54,10 @@ public sealed class SpdxCycloneDxConversionTests
Type = Component.Classification.Library
};
// Use v1_6 for Bom object; serialized output is upgraded to 1.7 via CycloneDx17Extensions
return new Bom
{
SpecVersion = SpecificationVersion.v1_7,
SpecVersion = SpecificationVersion.v1_6,
Version = 1,
Metadata = new Metadata
{

View File

@@ -0,0 +1,278 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Scanner.Emit.Lineage;
using Xunit;
namespace StellaOps.Scanner.Emit.Tests.Lineage;
/// <summary>
/// Tests for SBOM lineage models.
/// </summary>
public class SbomLineageTests
{
[Fact]
public void SbomId_New_CreatesUniqueIds()
{
var id1 = SbomId.New();
var id2 = SbomId.New();
id1.Should().NotBe(id2);
}
[Fact]
public void SbomId_Parse_RoundTrips()
{
var original = SbomId.New();
var parsed = SbomId.Parse(original.ToString());
parsed.Should().Be(original);
}
[Fact]
public void SbomLineage_WithParent_TracksAncestry()
{
var parentId = SbomId.New();
var childId = SbomId.New();
var lineage = new SbomLineage
{
Id = childId,
ParentId = parentId,
ImageDigest = "sha256:abc123",
ContentHash = "sha256:def456",
CreatedAt = DateTimeOffset.UtcNow,
Ancestors = [parentId]
};
lineage.ParentId.Should().Be(parentId);
lineage.Ancestors.Should().Contain(parentId);
}
[Fact]
public void SbomDiffPointer_TotalChanges_SumsCorrectly()
{
var pointer = new SbomDiffPointer
{
ComponentsAdded = 5,
ComponentsRemoved = 3,
ComponentsModified = 7,
DiffHash = "sha256:abc"
};
pointer.TotalChanges.Should().Be(15);
}
}
/// <summary>
/// Tests for SBOM diff engine.
/// </summary>
public class SbomDiffEngineTests
{
private readonly SbomDiffEngine _engine = new();
[Fact]
public void ComputeDiff_NoChanges_ReturnsEmptyDeltas()
{
var components = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21" }
};
var fromId = SbomId.New();
var toId = SbomId.New();
var diff = _engine.ComputeDiff(fromId, components, toId, components);
diff.Deltas.Should().BeEmpty();
diff.Summary.Unchanged.Should().Be(1);
diff.Summary.Added.Should().Be(0);
diff.Summary.Removed.Should().Be(0);
}
[Fact]
public void ComputeDiff_ComponentAdded_DetectsAddition()
{
var fromComponents = new List<ComponentRef>();
var toComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21" }
};
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
diff.Summary.Added.Should().Be(1);
diff.Deltas.Should().ContainSingle()
.Which.Type.Should().Be(ComponentDeltaType.Added);
}
[Fact]
public void ComputeDiff_ComponentRemoved_DetectsRemovalAndBreaking()
{
var fromComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21" }
};
var toComponents = new List<ComponentRef>();
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
diff.Summary.Removed.Should().Be(1);
diff.Summary.IsBreaking.Should().BeTrue();
diff.Deltas.Should().ContainSingle()
.Which.Type.Should().Be(ComponentDeltaType.Removed);
}
[Fact]
public void ComputeDiff_VersionChanged_DetectsVersionChange()
{
var fromComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.20" }
};
var toComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.21" }
};
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
diff.Summary.VersionChanged.Should().Be(1);
var delta = diff.Deltas.Should().ContainSingle().Subject;
delta.Type.Should().Be(ComponentDeltaType.VersionChanged);
delta.ChangedFields.Should().Contain("Version");
}
[Fact]
public void ComputeDiff_VersionDowngrade_IsBreaking()
{
var fromComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21" }
};
var toComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.20" }
};
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
diff.Summary.IsBreaking.Should().BeTrue();
}
[Fact]
public void ComputeDiff_LicenseChanged_DetectsLicenseChange()
{
var fromComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21", License = "MIT" }
};
var toComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.21", Name = "lodash", Version = "4.17.21", License = "Apache-2.0" }
};
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
diff.Summary.OtherModified.Should().Be(1);
var delta = diff.Deltas.Should().ContainSingle().Subject;
delta.Type.Should().Be(ComponentDeltaType.LicenseChanged);
delta.ChangedFields.Should().Contain("License");
}
[Fact]
public void ComputeDiff_IsDeterministic()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var fromComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/a@1.0.0", Name = "a", Version = "1.0.0" },
new() { Purl = "pkg:npm/b@1.0.0", Name = "b", Version = "1.0.0" }
};
var toComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/b@1.0.0", Name = "b", Version = "1.1.0" },
new() { Purl = "pkg:npm/c@1.0.0", Name = "c", Version = "1.0.0" }
};
var diff1 = _engine.ComputeDiff(fromId, fromComponents, toId, toComponents);
var diff2 = _engine.ComputeDiff(fromId, fromComponents, toId, toComponents);
// Deltas should be in same order
diff1.Deltas.Length.Should().Be(diff2.Deltas.Length);
for (int i = 0; i < diff1.Deltas.Length; i++)
{
diff1.Deltas[i].Type.Should().Be(diff2.Deltas[i].Type);
diff1.Deltas[i].Before?.Purl.Should().Be(diff2.Deltas[i].Before?.Purl);
diff1.Deltas[i].After?.Purl.Should().Be(diff2.Deltas[i].After?.Purl);
}
}
[Fact]
public void CreatePointer_SummarizesCorrectly()
{
var fromComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/a@1.0.0", Name = "a", Version = "1.0.0" }
};
var toComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/a@1.0.0", Name = "a", Version = "1.1.0" },
new() { Purl = "pkg:npm/b@1.0.0", Name = "b", Version = "1.0.0" }
};
var diff = _engine.ComputeDiff(SbomId.New(), fromComponents, SbomId.New(), toComponents);
var pointer = _engine.CreatePointer(diff);
pointer.ComponentsAdded.Should().Be(1);
pointer.ComponentsModified.Should().Be(1);
pointer.ComponentsRemoved.Should().Be(0);
pointer.DiffHash.Should().NotBeNullOrEmpty();
}
[Fact]
public void CreatePointer_HashIsDeterministic()
{
var fromId = SbomId.New();
var toId = SbomId.New();
var fromComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.20" }
};
var toComponents = new List<ComponentRef>
{
new() { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.21" }
};
var diff1 = _engine.ComputeDiff(fromId, fromComponents, toId, toComponents);
var diff2 = _engine.ComputeDiff(fromId, fromComponents, toId, toComponents);
var pointer1 = _engine.CreatePointer(diff1);
var pointer2 = _engine.CreatePointer(diff2);
pointer1.DiffHash.Should().Be(pointer2.DiffHash);
}
}
/// <summary>
/// Tests for DiffSummary calculations.
/// </summary>
public class DiffSummaryTests
{
[Fact]
public void TotalComponents_CalculatesCorrectly()
{
var summary = new DiffSummary
{
Added = 5,
Removed = 3,
VersionChanged = 2,
OtherModified = 1,
Unchanged = 10
};
// TotalComponents = Added + VersionChanged + OtherModified + Unchanged
summary.TotalComponents.Should().Be(18);
}
}

View File

@@ -4,6 +4,8 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
@@ -13,6 +15,12 @@
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="JsonSchema.Net" Version="7.3.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,250 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_6000_0004_0001 - Scanner Integration
// Task: T6 - Integration Tests
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Moq;
using StellaOps.BinaryIndex.Core.Services;
using BinaryIdentity = StellaOps.BinaryIndex.Core.Models.BinaryIdentity;
using BinaryFormat = StellaOps.BinaryIndex.Core.Models.BinaryFormat;
using StellaOps.Scanner.Worker.Processing;
using Xunit;
namespace StellaOps.Scanner.Worker.Tests;
public sealed class BinaryVulnerabilityAnalyzerTests
{
[Fact]
public async Task AnalyzeLayerAsync_WithNoBinaryPaths_ReturnsEmptyResult()
{
// Arrange
var mockVulnService = new Mock<IBinaryVulnerabilityService>();
var mockExtractor = new Mock<IBinaryFeatureExtractor>();
var mockLogger = new Mock<ILogger<BinaryVulnerabilityAnalyzer>>();
var analyzer = new BinaryVulnerabilityAnalyzer(
mockVulnService.Object,
mockExtractor.Object,
mockLogger.Object);
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
LayerDigest = "sha256:test",
BinaryPaths = Array.Empty<string>(),
OpenFile = _ => null
};
// Act
var result = await analyzer.AnalyzeLayerAsync(context);
// Assert
Assert.Empty(result.Findings);
Assert.Equal(0, result.ExtractedBinaryCount);
}
[Fact]
public async Task AnalyzeLayerAsync_WithBinaryPaths_ExtractsIdentitiesAndLooksUpVulnerabilities()
{
// Arrange
var scanId = Guid.NewGuid();
var layerDigest = "sha256:abc123";
var buildId = "0123456789abcdef0123456789abcdef01234567";
var mockIdentity = new BinaryIdentity
{
BinaryKey = $"{buildId}:sha256test",
BuildId = buildId,
BuildIdType = "gnu-build-id",
FileSha256 = "sha256test",
Format = BinaryFormat.Elf,
Architecture = "x86_64"
};
var mockVulnMatch = new BinaryVulnMatch
{
CveId = "CVE-2024-1234",
VulnerablePurl = "pkg:deb/debian/openssl@1.1.1k-1",
Method = MatchMethod.BuildIdCatalog,
Confidence = 0.95m,
Evidence = new MatchEvidence { BuildId = buildId }
};
var mockVulnService = new Mock<IBinaryVulnerabilityService>();
mockVulnService
.Setup(s => s.LookupBatchAsync(
It.IsAny<IEnumerable<BinaryIdentity>>(),
It.IsAny<LookupOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>.Empty
.Add(mockIdentity.BinaryKey, [mockVulnMatch]));
var mockExtractor = new Mock<IBinaryFeatureExtractor>();
mockExtractor
.Setup(e => e.ExtractIdentityAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(mockIdentity);
var mockLogger = new Mock<ILogger<BinaryVulnerabilityAnalyzer>>();
var analyzer = new BinaryVulnerabilityAnalyzer(
mockVulnService.Object,
mockExtractor.Object,
mockLogger.Object);
// Create a mock stream for the binary file
using var testStream = new MemoryStream([0x7F, 0x45, 0x4C, 0x46]); // ELF magic
var context = new BinaryLayerContext
{
ScanId = scanId,
LayerDigest = layerDigest,
BinaryPaths = ["/usr/lib/libtest.so"],
DetectedDistro = "debian",
DetectedRelease = "12",
OpenFile = path => path == "/usr/lib/libtest.so" ? new MemoryStream([0x7F, 0x45, 0x4C, 0x46]) : null
};
// Act
var result = await analyzer.AnalyzeLayerAsync(context);
// Assert
Assert.Single(result.Findings);
Assert.Equal("CVE-2024-1234", result.Findings[0].CveId);
Assert.Equal("pkg:deb/debian/openssl@1.1.1k-1", result.Findings[0].VulnerablePurl);
Assert.Equal("BuildIdCatalog", result.Findings[0].MatchMethod);
Assert.Equal(0.95m, result.Findings[0].Confidence);
Assert.Equal(1, result.ExtractedBinaryCount);
}
[Fact]
public async Task AnalyzeLayerAsync_WithFailedExtraction_ContinuesWithOtherFiles()
{
// Arrange
var mockVulnService = new Mock<IBinaryVulnerabilityService>();
mockVulnService
.Setup(s => s.LookupBatchAsync(
It.IsAny<IEnumerable<BinaryIdentity>>(),
It.IsAny<LookupOptions?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(ImmutableDictionary<string, ImmutableArray<BinaryVulnMatch>>.Empty);
var goodIdentity = new BinaryIdentity
{
BinaryKey = "good-binary",
FileSha256 = "sha256good",
Format = BinaryFormat.Elf,
Architecture = "x86_64"
};
var mockExtractor = new Mock<IBinaryFeatureExtractor>();
// First call throws, second call succeeds
var callCount = 0;
mockExtractor
.Setup(e => e.ExtractIdentityAsync(It.IsAny<Stream>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
if (callCount == 1)
throw new InvalidDataException("Not a valid binary");
return goodIdentity;
});
var mockLogger = new Mock<ILogger<BinaryVulnerabilityAnalyzer>>();
var analyzer = new BinaryVulnerabilityAnalyzer(
mockVulnService.Object,
mockExtractor.Object,
mockLogger.Object);
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
LayerDigest = "sha256:test",
BinaryPaths = ["/usr/lib/bad.so", "/usr/lib/good.so"],
OpenFile = _ => new MemoryStream([0x7F, 0x45, 0x4C, 0x46])
};
// Act
var result = await analyzer.AnalyzeLayerAsync(context);
// Assert
Assert.Equal(1, result.ExtractedBinaryCount);
Assert.Single(result.ExtractionErrors);
Assert.Contains("Not a valid binary", result.ExtractionErrors[0]);
}
[Fact]
public async Task AnalyzeLayerAsync_WithNoOpenableFiles_ReturnsEmptyResult()
{
// Arrange
var mockVulnService = new Mock<IBinaryVulnerabilityService>();
var mockExtractor = new Mock<IBinaryFeatureExtractor>();
var mockLogger = new Mock<ILogger<BinaryVulnerabilityAnalyzer>>();
var analyzer = new BinaryVulnerabilityAnalyzer(
mockVulnService.Object,
mockExtractor.Object,
mockLogger.Object);
var context = new BinaryLayerContext
{
ScanId = Guid.NewGuid(),
LayerDigest = "sha256:test",
BinaryPaths = ["/usr/lib/missing.so"],
OpenFile = _ => null // All files fail to open
};
// Act
var result = await analyzer.AnalyzeLayerAsync(context);
// Assert
Assert.Empty(result.Findings);
Assert.Equal(0, result.ExtractedBinaryCount);
}
[Fact]
public void BinaryVulnerabilityFinding_GetSummary_FormatsCorrectly()
{
// Arrange
var finding = new BinaryVulnerabilityFinding
{
ScanId = Guid.NewGuid(),
LayerDigest = "sha256:test",
BinaryKey = "testkey",
CveId = "CVE-2024-5678",
VulnerablePurl = "pkg:npm/lodash@4.17.20",
MatchMethod = "FingerprintMatch",
Confidence = 0.85m,
Evidence = null
};
// Act
var summary = finding.GetSummary();
// Assert
Assert.Contains("CVE-2024-5678", summary);
Assert.Contains("pkg:npm/lodash@4.17.20", summary);
Assert.Contains("FingerprintMatch", summary);
Assert.Contains("85%", summary);
}
[Fact]
public void BinaryAnalysisResult_Empty_ReturnsValidEmptyResult()
{
// Arrange
var scanId = Guid.NewGuid();
var layerDigest = "sha256:empty";
// Act
var result = BinaryAnalysisResult.Empty(scanId, layerDigest);
// Assert
Assert.Equal(scanId, result.ScanId);
Assert.Equal(layerDigest, result.LayerDigest);
Assert.Equal("binary-vulnerability", result.AnalyzerId);
Assert.Empty(result.Findings);
Assert.Equal(0, result.ExtractedBinaryCount);
Assert.Empty(result.ExtractionErrors);
}
}