521 lines
15 KiB
C#
521 lines
15 KiB
C#
// -----------------------------------------------------------------------------
|
|
// SbomParserTests.cs
|
|
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
|
// Task: SBOM-8200-007
|
|
// Description: Unit tests for SBOM parsing and PURL extraction
|
|
// Supports CycloneDX 1.4-1.7 and SPDX 2.2-2.3, 3.0
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Text;
|
|
using FluentAssertions;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using StellaOps.Concelier.SbomIntegration.Models;
|
|
using StellaOps.Concelier.SbomIntegration.Parsing;
|
|
using Xunit;
|
|
|
|
|
|
using StellaOps.TestKit;
|
|
namespace StellaOps.Concelier.SbomIntegration.Tests;
|
|
|
|
public class SbomParserTests
|
|
{
|
|
private readonly SbomParser _parser;
|
|
|
|
public SbomParserTests()
|
|
{
|
|
var loggerMock = new Mock<ILogger<SbomParser>>();
|
|
_parser = new SbomParser(loggerMock.Object);
|
|
}
|
|
|
|
#region CycloneDX Tests
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_CycloneDX_ExtractsPurls()
|
|
{
|
|
// Arrange
|
|
var cycloneDxContent = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "myapp",
|
|
"version": "1.0.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{
|
|
"type": "library",
|
|
"name": "lodash",
|
|
"version": "4.17.21",
|
|
"purl": "pkg:npm/lodash@4.17.21"
|
|
},
|
|
{
|
|
"type": "library",
|
|
"name": "express",
|
|
"version": "4.18.2",
|
|
"purl": "pkg:npm/express@4.18.2"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.PrimaryName.Should().Be("myapp");
|
|
result.PrimaryVersion.Should().Be("1.0.0");
|
|
result.Purls.Should().HaveCount(2);
|
|
result.Purls.Should().Contain("pkg:npm/lodash@4.17.21");
|
|
result.Purls.Should().Contain("pkg:npm/express@4.18.2");
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_CycloneDX_HandlesNestedComponents()
|
|
{
|
|
// Arrange
|
|
var cycloneDxContent = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.5",
|
|
"components": [
|
|
{
|
|
"type": "library",
|
|
"name": "parent",
|
|
"version": "1.0.0",
|
|
"purl": "pkg:npm/parent@1.0.0",
|
|
"components": [
|
|
{
|
|
"type": "library",
|
|
"name": "child",
|
|
"version": "2.0.0",
|
|
"purl": "pkg:npm/child@2.0.0"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
// Assert
|
|
result.Purls.Should().Contain("pkg:npm/parent@1.0.0");
|
|
result.Purls.Should().Contain("pkg:npm/child@2.0.0");
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_CycloneDX_SkipsComponentsWithoutPurl()
|
|
{
|
|
// Arrange
|
|
var cycloneDxContent = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"components": [
|
|
{
|
|
"type": "library",
|
|
"name": "with-purl",
|
|
"version": "1.0.0",
|
|
"purl": "pkg:npm/with-purl@1.0.0"
|
|
},
|
|
{
|
|
"type": "library",
|
|
"name": "without-purl",
|
|
"version": "1.0.0"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
// Assert
|
|
result.Purls.Should().HaveCount(1);
|
|
result.Purls.Should().Contain("pkg:npm/with-purl@1.0.0");
|
|
result.UnresolvedComponents.Should().HaveCount(1);
|
|
result.UnresolvedComponents[0].Name.Should().Be("without-purl");
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_CycloneDX_DeduplicatesPurls()
|
|
{
|
|
// Arrange
|
|
var cycloneDxContent = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"components": [
|
|
{
|
|
"type": "library",
|
|
"purl": "pkg:npm/lodash@4.17.21"
|
|
},
|
|
{
|
|
"type": "library",
|
|
"purl": "pkg:npm/lodash@4.17.21"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
// Assert
|
|
result.Purls.Should().HaveCount(1);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_CycloneDX17_ExtractsPurls()
|
|
{
|
|
// Arrange - CycloneDX 1.7 format
|
|
var cycloneDxContent = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.7",
|
|
"version": 1,
|
|
"metadata": {
|
|
"component": {
|
|
"type": "application",
|
|
"name": "myapp",
|
|
"version": "2.0.0"
|
|
}
|
|
},
|
|
"components": [
|
|
{
|
|
"type": "library",
|
|
"name": "axios",
|
|
"version": "1.6.0",
|
|
"purl": "pkg:npm/axios@1.6.0"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(cycloneDxContent));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.PrimaryName.Should().Be("myapp");
|
|
result.Purls.Should().Contain("pkg:npm/axios@1.6.0");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region SPDX Tests
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_SPDX_ExtractsPurls()
|
|
{
|
|
// Arrange
|
|
var spdxContent = """
|
|
{
|
|
"spdxVersion": "SPDX-2.3",
|
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
"name": "myapp-sbom",
|
|
"packages": [
|
|
{
|
|
"SPDXID": "SPDXRef-Package-npm-lodash",
|
|
"name": "lodash",
|
|
"versionInfo": "4.17.21",
|
|
"externalRefs": [
|
|
{
|
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
"referenceType": "purl",
|
|
"referenceLocator": "pkg:npm/lodash@4.17.21"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"SPDXID": "SPDXRef-Package-npm-express",
|
|
"name": "express",
|
|
"versionInfo": "4.18.2",
|
|
"externalRefs": [
|
|
{
|
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
"referenceType": "purl",
|
|
"referenceLocator": "pkg:npm/express@4.18.2"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(spdxContent));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.SPDX);
|
|
|
|
// Assert
|
|
result.Purls.Should().HaveCount(2);
|
|
result.Purls.Should().Contain("pkg:npm/lodash@4.17.21");
|
|
result.Purls.Should().Contain("pkg:npm/express@4.18.2");
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_SPDX_IgnoresNonPurlExternalRefs()
|
|
{
|
|
// Arrange
|
|
var spdxContent = """
|
|
{
|
|
"spdxVersion": "SPDX-2.3",
|
|
"packages": [
|
|
{
|
|
"SPDXID": "SPDXRef-Package",
|
|
"name": "mypackage",
|
|
"externalRefs": [
|
|
{
|
|
"referenceCategory": "SECURITY",
|
|
"referenceType": "cpe23Type",
|
|
"referenceLocator": "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"
|
|
},
|
|
{
|
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
"referenceType": "purl",
|
|
"referenceLocator": "pkg:npm/mypackage@1.0.0"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(spdxContent));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.SPDX);
|
|
|
|
// Assert
|
|
result.Purls.Should().HaveCount(1);
|
|
result.Purls.Should().Contain("pkg:npm/mypackage@1.0.0");
|
|
result.Cpes.Should().HaveCount(1);
|
|
result.Cpes.Should().Contain("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*");
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Format Detection Tests
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Theory]
|
|
[InlineData("1.4")]
|
|
[InlineData("1.5")]
|
|
[InlineData("1.6")]
|
|
[InlineData("1.7")]
|
|
public async Task DetectFormatAsync_CycloneDX_DetectsAllVersions(string specVersion)
|
|
{
|
|
// Arrange
|
|
var content = $$"""
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "{{specVersion}}",
|
|
"components": []
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
|
|
|
// Act
|
|
var result = await _parser.DetectFormatAsync(stream);
|
|
|
|
// Assert
|
|
result.IsDetected.Should().BeTrue();
|
|
result.Format.Should().Be(SbomFormat.CycloneDX);
|
|
result.SpecVersion.Should().Be(specVersion);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFormatAsync_SPDX2_DetectsFormat()
|
|
{
|
|
// Arrange
|
|
var content = """
|
|
{
|
|
"spdxVersion": "SPDX-2.3",
|
|
"packages": []
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
|
|
|
// Act
|
|
var result = await _parser.DetectFormatAsync(stream);
|
|
|
|
// Assert
|
|
result.IsDetected.Should().BeTrue();
|
|
result.Format.Should().Be(SbomFormat.SPDX);
|
|
result.SpecVersion.Should().Be("SPDX-2.3");
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFormatAsync_UnknownFormat_ReturnsNotDetected()
|
|
{
|
|
// Arrange
|
|
var content = """
|
|
{
|
|
"unknownField": "value"
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
|
|
|
// Act
|
|
var result = await _parser.DetectFormatAsync(stream);
|
|
|
|
// Assert
|
|
result.IsDetected.Should().BeFalse();
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task DetectFormatAsync_InvalidJson_ReturnsNotDetected()
|
|
{
|
|
// Arrange
|
|
var content = "not valid json {{{";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
|
|
|
// Act
|
|
var result = await _parser.DetectFormatAsync(stream);
|
|
|
|
// Assert
|
|
result.IsDetected.Should().BeFalse();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region PURL Ecosystem Tests
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Theory]
|
|
[InlineData("pkg:npm/lodash@4.17.21")]
|
|
[InlineData("pkg:pypi/requests@2.28.0")]
|
|
[InlineData("pkg:maven/org.apache.commons/commons-lang3@3.12.0")]
|
|
[InlineData("pkg:nuget/Newtonsoft.Json@13.0.1")]
|
|
[InlineData("pkg:cargo/serde@1.0.150")]
|
|
[InlineData("pkg:golang/github.com/gin-gonic/gin@1.9.0")]
|
|
[InlineData("pkg:gem/rails@7.0.4")]
|
|
[InlineData("pkg:deb/debian/openssl@1.1.1n-0+deb11u3")]
|
|
[InlineData("pkg:rpm/fedora/kernel@5.19.0-43.fc37")]
|
|
[InlineData("pkg:apk/alpine/openssl@1.1.1q-r0")]
|
|
public async Task ParseAsync_CycloneDX_SupportsVariousEcosystems(string purl)
|
|
{
|
|
// Arrange
|
|
var content = $$"""
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"components": [
|
|
{
|
|
"type": "library",
|
|
"purl": "{{purl}}"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
// Assert
|
|
result.Purls.Should().Contain(purl);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Edge Cases
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_EmptyComponents_ReturnsEmptyPurls()
|
|
{
|
|
// Arrange
|
|
var content = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"components": []
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
// Assert
|
|
result.Purls.Should().BeEmpty();
|
|
result.TotalComponents.Should().Be(0);
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_NullStream_ThrowsArgumentNullException()
|
|
{
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
|
_parser.ParseAsync(null!, SbomFormat.CycloneDX));
|
|
}
|
|
|
|
[Trait("Category", TestCategories.Unit)]
|
|
[Fact]
|
|
public async Task ParseAsync_ExtractsCpes()
|
|
{
|
|
// Arrange
|
|
var content = """
|
|
{
|
|
"bomFormat": "CycloneDX",
|
|
"specVersion": "1.6",
|
|
"components": [
|
|
{
|
|
"type": "library",
|
|
"name": "openssl",
|
|
"cpe": "cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*",
|
|
"purl": "pkg:deb/debian/openssl@1.1.1"
|
|
}
|
|
]
|
|
}
|
|
""";
|
|
|
|
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
|
|
|
// Act
|
|
var result = await _parser.ParseAsync(stream, SbomFormat.CycloneDX);
|
|
|
|
// Assert
|
|
result.Cpes.Should().HaveCount(1);
|
|
result.Cpes.Should().Contain("cpe:2.3:a:openssl:openssl:1.1.1:*:*:*:*:*:*:*");
|
|
}
|
|
|
|
#endregion
|
|
}
|